Migrate frontend from JavaScript to TypeScript
- Rename all 54 .js files to .ts, update esbuild entry point - Add tsconfig.json, TypeScript devDependency, typecheck script - Create types.ts with 25+ interfaces matching backend Pydantic schemas (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.) - Make DataCache generic (DataCache<T>) with typed state instances - Type all state variables in state.ts with proper entity types - Type all create*Card functions with proper entity interfaces - Type all function parameters and return types across all 54 files - Type core component constructors (CardSection, IconSelect, EntitySelect, FilterList, TagInput, TreeNav, Modal) with exported option interfaces - Add comprehensive global.d.ts for window function declarations - Type fetchWithAuth with FetchAuthOpts interface - Remove all (window as any) casts in favor of global.d.ts declarations - Zero tsc errors, esbuild bundle unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ const watch = process.argv.includes('--watch');
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const jsOpts = {
|
||||
entryPoints: [`${srcDir}/js/app.js`],
|
||||
entryPoints: [`${srcDir}/js/app.ts`],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: `${outDir}/app.bundle.js`,
|
||||
|
||||
22
server/package-lock.json
generated
22
server/package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
"elkjs": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -493,6 +494,19 @@
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -729,6 +743,12 @@
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch"
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
|
||||
@@ -3,34 +3,34 @@
|
||||
*/
|
||||
|
||||
// Layer 0: state
|
||||
import { apiKey, setApiKey, refreshInterval } from './core/state.js';
|
||||
import { Modal } from './core/modal.js';
|
||||
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
|
||||
import { Modal } from './core/modal.ts';
|
||||
|
||||
// Layer 1: api, i18n
|
||||
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.js';
|
||||
import { t, initLocale, changeLocale } from './core/i18n.js';
|
||||
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
|
||||
import { t, initLocale, changeLocale } from './core/i18n.ts';
|
||||
|
||||
// Layer 1.5: visual effects
|
||||
import { initCardGlare } from './core/card-glare.js';
|
||||
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.js';
|
||||
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.js';
|
||||
import { initCardGlare } from './core/card-glare.ts';
|
||||
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
|
||||
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
|
||||
|
||||
// Layer 2: ui
|
||||
import {
|
||||
toggleHint, lockBody, unlockBody, closeLightbox,
|
||||
showToast, showConfirm, closeConfirmModal,
|
||||
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
||||
} from './core/ui.js';
|
||||
} from './core/ui.ts';
|
||||
|
||||
// Layer 3: displays, tutorials
|
||||
import {
|
||||
openDisplayPicker, closeDisplayPicker, selectDisplay, formatDisplayLabel,
|
||||
} from './features/displays.js';
|
||||
} from './features/displays.ts';
|
||||
import {
|
||||
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
|
||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||
closeTutorial, tutorialNext, tutorialPrev,
|
||||
} from './features/tutorials.js';
|
||||
} from './features/tutorials.ts';
|
||||
|
||||
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations
|
||||
import {
|
||||
@@ -38,18 +38,18 @@ import {
|
||||
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
|
||||
turnOffDevice, pingDevice, removeDevice, loadDevices,
|
||||
updateSettingsBaudFpsHint, copyWsUrl,
|
||||
} from './features/devices.js';
|
||||
} from './features/devices.ts';
|
||||
import {
|
||||
loadDashboard, stopUptimeTimer,
|
||||
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
||||
dashboardPauseClock, dashboardResumeClock, dashboardResetClock,
|
||||
toggleDashboardSection, changeDashboardPollInterval,
|
||||
} from './features/dashboard.js';
|
||||
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
||||
import { startEntityEventListeners } from './core/entity-events.js';
|
||||
} from './features/dashboard.ts';
|
||||
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
||||
import { startEntityEventListeners } from './core/entity-events.ts';
|
||||
import {
|
||||
startPerfPolling, stopPerfPolling,
|
||||
} from './features/perf-charts.js';
|
||||
} from './features/perf-charts.ts';
|
||||
import {
|
||||
loadPictureSources, switchStreamTab,
|
||||
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
||||
@@ -68,14 +68,14 @@ import {
|
||||
showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT,
|
||||
csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption,
|
||||
renderCSPTModalFilterList,
|
||||
} from './features/streams.js';
|
||||
} from './features/streams.ts';
|
||||
import {
|
||||
createKCTargetCard, testKCTarget,
|
||||
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
|
||||
deleteKCTarget, disconnectAllKCWebSockets,
|
||||
updateKCBrightnessLabel, saveKCBrightness,
|
||||
cloneKCTarget,
|
||||
} from './features/kc-targets.js';
|
||||
} from './features/kc-targets.ts';
|
||||
import {
|
||||
createPatternTemplateCard,
|
||||
showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal,
|
||||
@@ -84,24 +84,24 @@ import {
|
||||
addPatternRect, deleteSelectedPatternRect, removePatternRect,
|
||||
capturePatternBackground,
|
||||
clonePatternTemplate,
|
||||
} from './features/pattern-templates.js';
|
||||
} from './features/pattern-templates.ts';
|
||||
import {
|
||||
loadAutomations, openAutomationEditor, closeAutomationEditorModal,
|
||||
saveAutomationEditor, addAutomationCondition,
|
||||
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
||||
} from './features/automations.js';
|
||||
} from './features/automations.ts';
|
||||
import {
|
||||
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
||||
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
|
||||
addSceneTarget, removeSceneTarget,
|
||||
} from './features/scene-presets.js';
|
||||
} from './features/scene-presets.ts';
|
||||
|
||||
// Layer 5: device-discovery, targets
|
||||
import {
|
||||
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
|
||||
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
|
||||
cloneDevice,
|
||||
} from './features/device-discovery.js';
|
||||
} from './features/device-discovery.ts';
|
||||
import {
|
||||
loadTargetsTab, switchTargetSubTab,
|
||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||
@@ -110,7 +110,7 @@ import {
|
||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||
cloneTarget, toggleLedPreview,
|
||||
disconnectAllLedPreviewWS,
|
||||
} from './features/targets.js';
|
||||
} from './features/targets.ts';
|
||||
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
} from './features/color-strips.js';
|
||||
} from './features/color-strips.ts';
|
||||
|
||||
// Layer 5: audio sources
|
||||
import {
|
||||
@@ -142,7 +142,7 @@ import {
|
||||
editAudioSource, cloneAudioSource, deleteAudioSource,
|
||||
testAudioSource, closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
} from './features/audio-sources.js';
|
||||
} from './features/audio-sources.ts';
|
||||
|
||||
// Layer 5: value sources
|
||||
import {
|
||||
@@ -151,7 +151,7 @@ import {
|
||||
onDaylightVSRealTimeChange,
|
||||
addSchedulePoint,
|
||||
testValueSource, closeTestValueSourceModal,
|
||||
} from './features/value-sources.js';
|
||||
} from './features/value-sources.ts';
|
||||
|
||||
// Layer 5: calibration
|
||||
import {
|
||||
@@ -159,12 +159,12 @@ import {
|
||||
updateOffsetSkipLock, updateCalibrationPreview,
|
||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||
showCSSCalibration, toggleCalibrationOverlay,
|
||||
} from './features/calibration.js';
|
||||
} from './features/calibration.ts';
|
||||
import {
|
||||
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
||||
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
||||
updateCalibrationLine, resetCalibrationView,
|
||||
} from './features/advanced-calibration.js';
|
||||
} from './features/advanced-calibration.ts';
|
||||
|
||||
// Layer 5.5: graph editor
|
||||
import {
|
||||
@@ -172,12 +172,12 @@ import {
|
||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
||||
graphToggleFullscreen, graphAddEntity,
|
||||
} from './features/graph-editor.js';
|
||||
} from './features/graph-editor.ts';
|
||||
|
||||
// Layer 6: tabs, navigation, command palette, settings
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
||||
import { navigateToCard } from './core/navigation.js';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
|
||||
import { navigateToCard } from './core/navigation.ts';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
@@ -189,7 +189,7 @@ import {
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.js';
|
||||
} from './features/settings.ts';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
|
||||
@@ -575,9 +575,9 @@ document.addEventListener('keydown', (e) => {
|
||||
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||
closeLogOverlay();
|
||||
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
closeDisplayPicker();
|
||||
closeDisplayPicker(null as any);
|
||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||
closeLightbox();
|
||||
closeLightbox(null as any);
|
||||
} else {
|
||||
Modal.closeTopmost();
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
* API utilities — base URL, auth headers, fetch wrapper, helpers.
|
||||
*/
|
||||
|
||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
export const API_BASE = '/api/v1';
|
||||
|
||||
export function getHeaders() {
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (apiKey) {
|
||||
@@ -18,7 +18,10 @@ export function getHeaders() {
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
status: number;
|
||||
isAuth: boolean;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
@@ -26,7 +29,13 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(url, options = {}) {
|
||||
interface FetchAuthOpts extends RequestInit {
|
||||
retry?: boolean;
|
||||
timeout?: number;
|
||||
handle401?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): Promise<Response> {
|
||||
const { retry = true, timeout = 10000, handle401: auto401 = true, ...fetchOpts } = options;
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
||||
const headers = fetchOpts.headers
|
||||
@@ -62,60 +71,61 @@ export async function fetchWithAuth(url, options = {}) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return undefined as unknown as Response;
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
export function escapeHtml(text: string) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function isSerialDevice(type) {
|
||||
export function isSerialDevice(type: string) {
|
||||
return type === 'adalight' || type === 'ambiled';
|
||||
}
|
||||
|
||||
export function isMockDevice(type) {
|
||||
export function isMockDevice(type: string) {
|
||||
return type === 'mock';
|
||||
}
|
||||
|
||||
export function isMqttDevice(type) {
|
||||
export function isMqttDevice(type: string) {
|
||||
return type === 'mqtt';
|
||||
}
|
||||
|
||||
export function isWsDevice(type) {
|
||||
export function isWsDevice(type: string) {
|
||||
return type === 'ws';
|
||||
}
|
||||
|
||||
export function isOpenrgbDevice(type) {
|
||||
export function isOpenrgbDevice(type: string) {
|
||||
return type === 'openrgb';
|
||||
}
|
||||
|
||||
export function isDmxDevice(type) {
|
||||
export function isDmxDevice(type: string) {
|
||||
return type === 'dmx';
|
||||
}
|
||||
|
||||
export function isEspnowDevice(type) {
|
||||
export function isEspnowDevice(type: string) {
|
||||
return type === 'espnow';
|
||||
}
|
||||
|
||||
export function isHueDevice(type) {
|
||||
export function isHueDevice(type: string) {
|
||||
return type === 'hue';
|
||||
}
|
||||
|
||||
export function isUsbhidDevice(type) {
|
||||
export function isUsbhidDevice(type: string) {
|
||||
return type === 'usbhid';
|
||||
}
|
||||
|
||||
export function isSpiDevice(type) {
|
||||
export function isSpiDevice(type: string) {
|
||||
return type === 'spi';
|
||||
}
|
||||
|
||||
export function isChromaDevice(type) {
|
||||
export function isChromaDevice(type: string) {
|
||||
return type === 'chroma';
|
||||
}
|
||||
|
||||
export function isGameSenseDevice(type) {
|
||||
export function isGameSenseDevice(type: string) {
|
||||
return type === 'gamesense';
|
||||
}
|
||||
|
||||
@@ -147,19 +157,19 @@ export function handle401Error() {
|
||||
}
|
||||
}
|
||||
|
||||
let _connCheckTimer = null;
|
||||
let _serverOnline = null; // null = unknown, true/false
|
||||
let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _serverOnline: boolean | null = null; // null = unknown, true/false
|
||||
|
||||
function _setConnectionState(online) {
|
||||
function _setConnectionState(online: boolean) {
|
||||
const changed = _serverOnline !== online;
|
||||
_serverOnline = online;
|
||||
const banner = document.getElementById('connection-overlay');
|
||||
const badge = document.getElementById('server-status');
|
||||
if (online) {
|
||||
if (banner) { banner.style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
||||
if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
||||
if (badge) badge.className = 'status-badge online';
|
||||
} else {
|
||||
if (banner) { banner.style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); }
|
||||
if (banner) { (banner as HTMLElement).style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); }
|
||||
if (badge) badge.className = 'status-badge offline';
|
||||
}
|
||||
return changed;
|
||||
@@ -170,8 +180,8 @@ export async function loadServerInfo() {
|
||||
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('version-number').textContent = `v${data.version}`;
|
||||
document.getElementById('server-status').textContent = '●';
|
||||
document.getElementById('version-number')!.textContent = `v${data.version}`;
|
||||
document.getElementById('server-status')!.textContent = '●';
|
||||
const wasOffline = _serverOnline === false;
|
||||
_setConnectionState(true);
|
||||
if (wasOffline) {
|
||||
@@ -200,7 +210,7 @@ export function stopConnectionMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDisplays(engineType = null) {
|
||||
export async function loadDisplays(engineType: string | null = null) {
|
||||
if (engineType) {
|
||||
// Filtered fetch — bypass cache (engine-specific display list)
|
||||
try {
|
||||
@@ -233,11 +243,11 @@ export function configureApiKey() {
|
||||
if (key === '') {
|
||||
localStorage.removeItem('wled_api_key');
|
||||
setApiKey(null);
|
||||
document.getElementById('api-key-btn').style.display = 'none';
|
||||
document.getElementById('api-key-btn')!.style.display = 'none';
|
||||
} else {
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
setApiKey(key);
|
||||
document.getElementById('api-key-btn').style.display = 'inline-block';
|
||||
document.getElementById('api-key-btn')!.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
loadServerInfo();
|
||||
@@ -108,8 +108,8 @@ void main() {
|
||||
|
||||
let _canvas, _gl, _prog;
|
||||
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
|
||||
let _particleBuf = null; // pre-allocated Float32Array for uniform3fv
|
||||
let _raf = null;
|
||||
let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
|
||||
let _raf: number | null = null;
|
||||
let _startTime = 0;
|
||||
let _accent = [76 / 255, 175 / 255, 80 / 255];
|
||||
let _bgColor = [26 / 255, 26 / 255, 26 / 255];
|
||||
@@ -118,7 +118,7 @@ let _isLight = 0.0;
|
||||
// Particle state (CPU-side, positions in 0..1 UV space)
|
||||
const _particles = [];
|
||||
|
||||
function _initParticles() {
|
||||
function _initParticles(): void {
|
||||
_particles.length = 0;
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
_particles.push({
|
||||
@@ -131,7 +131,7 @@ function _initParticles() {
|
||||
}
|
||||
}
|
||||
|
||||
function _updateParticles() {
|
||||
function _updateParticles(): void {
|
||||
for (const p of _particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
@@ -142,7 +142,7 @@ function _updateParticles() {
|
||||
}
|
||||
}
|
||||
|
||||
function _compile(gl, type, src) {
|
||||
function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
@@ -153,7 +153,7 @@ function _compile(gl, type, src) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function _initGL() {
|
||||
function _initGL(): boolean {
|
||||
_gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false });
|
||||
if (!_gl) return false;
|
||||
const gl = _gl;
|
||||
@@ -191,7 +191,7 @@ function _initGL() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function _resize() {
|
||||
function _resize(): void {
|
||||
const w = Math.round(window.innerWidth * 0.5);
|
||||
const h = Math.round(window.innerHeight * 0.5);
|
||||
_canvas.width = w;
|
||||
@@ -199,7 +199,7 @@ function _resize() {
|
||||
if (_gl) _gl.viewport(0, 0, w, h);
|
||||
}
|
||||
|
||||
function _draw(time) {
|
||||
function _draw(time: number): void {
|
||||
_raf = requestAnimationFrame(_draw);
|
||||
const gl = _gl;
|
||||
if (!gl) return;
|
||||
@@ -225,7 +225,7 @@ function _draw(time) {
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
function _start() {
|
||||
function _start(): void {
|
||||
if (_raf) return;
|
||||
if (!_gl && !_initGL()) return;
|
||||
_resize();
|
||||
@@ -234,14 +234,14 @@ function _start() {
|
||||
_raf = requestAnimationFrame(_draw);
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
function _stop(): void {
|
||||
if (_raf) {
|
||||
cancelAnimationFrame(_raf);
|
||||
_raf = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hexToNorm(hex) {
|
||||
function hexToNorm(hex: string): number[] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16) / 255,
|
||||
parseInt(hex.slice(3, 5), 16) / 255,
|
||||
@@ -249,16 +249,16 @@ function hexToNorm(hex) {
|
||||
];
|
||||
}
|
||||
|
||||
export function updateBgAnimAccent(hex) {
|
||||
export function updateBgAnimAccent(hex: string): void {
|
||||
_accent = hexToNorm(hex);
|
||||
}
|
||||
|
||||
export function updateBgAnimTheme(isDark) {
|
||||
export function updateBgAnimTheme(isDark: boolean): void {
|
||||
_bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255];
|
||||
_isLight = isDark ? 0.0 : 1.0;
|
||||
}
|
||||
|
||||
export function initBgAnim() {
|
||||
export function initBgAnim(): void {
|
||||
_canvas = document.getElementById('bg-anim-canvas');
|
||||
if (!_canvas) return;
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
* - icon: SVG HTML string (from icons.js) — rendered inside the button
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { showConfirm } from './ui.js';
|
||||
import { t } from './i18n.ts';
|
||||
import { showConfirm } from './ui.ts';
|
||||
import type { CardSection } from './card-sections.ts';
|
||||
|
||||
let _activeSection = null; // CardSection currently in bulk mode
|
||||
let _toolbarEl = null; // cached DOM element
|
||||
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
|
||||
let _toolbarEl: HTMLDivElement | null = null; // cached DOM element
|
||||
|
||||
function _ensureEl() {
|
||||
if (_toolbarEl) return _toolbarEl;
|
||||
@@ -72,13 +73,13 @@ function _render() {
|
||||
|
||||
// Select All checkbox
|
||||
el.querySelector('.bulk-select-all-cb').addEventListener('change', (e) => {
|
||||
if (e.target.checked) section.selectAll();
|
||||
if ((e.target as HTMLInputElement).checked) section.selectAll();
|
||||
else section.deselectAll();
|
||||
});
|
||||
|
||||
// Action buttons
|
||||
el.querySelectorAll('[data-bulk-action]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _executeAction(btn.dataset.bulkAction));
|
||||
btn.addEventListener('click', () => _executeAction((btn as HTMLElement).dataset.bulkAction));
|
||||
});
|
||||
|
||||
// Close button
|
||||
@@ -2,16 +2,33 @@
|
||||
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.js';
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
|
||||
export type ExtractDataFn<T> = (json: any) => T;
|
||||
export type SubscriberFn<T> = (data: T) => void;
|
||||
|
||||
export interface DataCacheOpts<T> {
|
||||
endpoint: string;
|
||||
extractData: ExtractDataFn<T>;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
export class DataCache<T = any> {
|
||||
private _endpoint: string;
|
||||
private _extractData: ExtractDataFn<T>;
|
||||
private _defaultValue: T;
|
||||
private _data: T;
|
||||
private _loading: boolean;
|
||||
private _promise: Promise<T> | null;
|
||||
private _subscribers: SubscriberFn<T>[];
|
||||
private _fresh: boolean;
|
||||
|
||||
export class DataCache {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.endpoint - API path (e.g. '/picture-sources')
|
||||
* @param {function} opts.extractData - Extract array from response JSON
|
||||
* @param {*} [opts.defaultValue=[]] - Initial/fallback value
|
||||
* @param opts.endpoint - API path (e.g. '/picture-sources')
|
||||
* @param opts.extractData - Extract array from response JSON
|
||||
* @param opts.defaultValue - Initial/fallback value (default: [])
|
||||
*/
|
||||
constructor({ endpoint, extractData, defaultValue = [] }) {
|
||||
constructor({ endpoint, extractData, defaultValue = [] as unknown as T }: DataCacheOpts<T>) {
|
||||
this._endpoint = endpoint;
|
||||
this._extractData = extractData;
|
||||
this._defaultValue = defaultValue;
|
||||
@@ -22,16 +39,14 @@ export class DataCache {
|
||||
this._fresh = false; // true after first successful fetch, cleared on invalidate
|
||||
}
|
||||
|
||||
get data() { return this._data; }
|
||||
get loading() { return this._loading; }
|
||||
get data(): T { return this._data; }
|
||||
get loading(): boolean { return this._loading; }
|
||||
|
||||
/**
|
||||
* Fetch from API. Deduplicates concurrent calls.
|
||||
* Returns cached data immediately if already fetched and not invalidated.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.force=false] - Force re-fetch even if cache is fresh
|
||||
*/
|
||||
async fetch({ force = false } = {}) {
|
||||
async fetch({ force = false } = {}): Promise<T> {
|
||||
if (!force && this._fresh) return this._data;
|
||||
if (this._promise) return this._promise;
|
||||
this._loading = true;
|
||||
@@ -44,7 +59,7 @@ export class DataCache {
|
||||
}
|
||||
}
|
||||
|
||||
async _doFetch() {
|
||||
async _doFetch(): Promise<T> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(this._endpoint);
|
||||
if (!resp.ok) {
|
||||
@@ -56,7 +71,7 @@ export class DataCache {
|
||||
this._fresh = true;
|
||||
this._notify();
|
||||
return this._data;
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return this._data;
|
||||
console.error(`Cache fetch ${this._endpoint}:`, err);
|
||||
return this._data;
|
||||
@@ -64,21 +79,21 @@ export class DataCache {
|
||||
}
|
||||
|
||||
/** Mark cache as stale; next fetch() will re-request. */
|
||||
invalidate() {
|
||||
invalidate(): void {
|
||||
this._fresh = false;
|
||||
}
|
||||
|
||||
/** Manually set cache value (e.g. after a create/update call). */
|
||||
update(value) {
|
||||
update(value: T): void {
|
||||
this._data = value;
|
||||
this._fresh = true;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
subscribe(fn) { this._subscribers.push(fn); }
|
||||
unsubscribe(fn) { this._subscribers = this._subscribers.filter(f => f !== fn); }
|
||||
subscribe(fn: SubscriberFn<T>): void { this._subscribers.push(fn); }
|
||||
unsubscribe(fn: SubscriberFn<T>): void { this._subscribers = this._subscribers.filter(f => f !== fn); }
|
||||
|
||||
_notify() {
|
||||
_notify(): void {
|
||||
for (const fn of this._subscribers) fn(this._data);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Card color assignment — localStorage-backed color labels for any card.
|
||||
*
|
||||
* Usage in card creation functions:
|
||||
* import { wrapCard } from '../core/card-colors.js';
|
||||
* import { wrapCard } from '../core/card-colors.ts';
|
||||
*
|
||||
* return wrapCard({
|
||||
* dataAttr: 'data-device-id',
|
||||
@@ -19,21 +19,21 @@
|
||||
* - Bottom actions (.card-actions / .template-card-actions) with color picker
|
||||
*/
|
||||
|
||||
import { createColorPicker, registerColorPicker } from './color-picker.js';
|
||||
import { createColorPicker, registerColorPicker } from './color-picker.ts';
|
||||
|
||||
const STORAGE_KEY = 'cardColors';
|
||||
const DEFAULT_SWATCH = '#808080';
|
||||
|
||||
function _getAll() {
|
||||
function _getAll(): Record<string, string> {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export function getCardColor(id) {
|
||||
export function getCardColor(id: string): string {
|
||||
return _getAll()[id] || '';
|
||||
}
|
||||
|
||||
export function setCardColor(id, hex) {
|
||||
export function setCardColor(id: string, hex: string): void {
|
||||
const m = _getAll();
|
||||
if (hex) m[id] = hex; else delete m[id];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
|
||||
@@ -43,7 +43,7 @@ export function setCardColor(id, hex) {
|
||||
* Returns inline style string for card border-left.
|
||||
* Empty string when no color is set.
|
||||
*/
|
||||
export function cardColorStyle(entityId) {
|
||||
export function cardColorStyle(entityId: string): string {
|
||||
const c = getCardColor(entityId);
|
||||
return c ? `border-left: 3px solid ${c}` : '';
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function cardColorStyle(entityId) {
|
||||
* @param {string} entityId Unique entity ID
|
||||
* @param {string} cardAttr Data attribute selector, e.g. 'data-device-id'
|
||||
*/
|
||||
export function cardColorButton(entityId, cardAttr) {
|
||||
export function cardColorButton(entityId: string, cardAttr: string): string {
|
||||
const color = getCardColor(entityId) || DEFAULT_SWATCH;
|
||||
const pickerId = `cc-${entityId}`;
|
||||
|
||||
@@ -62,11 +62,11 @@ export function cardColorButton(entityId, cardAttr) {
|
||||
// Find the card that contains this picker (not a global querySelector
|
||||
// which could match a dashboard compact card first)
|
||||
const wrapper = document.getElementById(`cp-wrap-${pickerId}`);
|
||||
const card = wrapper?.closest(`[${cardAttr}]`);
|
||||
const card = wrapper?.closest(`[${cardAttr}]`) as HTMLElement | null;
|
||||
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
|
||||
});
|
||||
|
||||
return createColorPicker({ id: pickerId, currentColor: color, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
|
||||
return createColorPicker({ id: pickerId, currentColor: color, onPick: null, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +98,17 @@ export function wrapCard({
|
||||
removeTitle,
|
||||
content,
|
||||
actions,
|
||||
}) {
|
||||
}: {
|
||||
type?: 'card' | 'template-card';
|
||||
dataAttr: string;
|
||||
id: string;
|
||||
classes?: string;
|
||||
topButtons?: string;
|
||||
removeOnclick: string;
|
||||
removeTitle: string;
|
||||
content: string;
|
||||
actions: string;
|
||||
}): string {
|
||||
const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
|
||||
const colorStyle = cardColorStyle(id);
|
||||
return `
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
const CARD_SEL = '.card, .template-card';
|
||||
|
||||
let _active = null; // currently illuminated card element
|
||||
let _cachedRect = null; // cached bounding rect for current card
|
||||
let _active: Element | null = null; // currently illuminated card element
|
||||
let _cachedRect: DOMRect | null = null; // cached bounding rect for current card
|
||||
|
||||
function _onMove(e) {
|
||||
const card = e.target.closest(CARD_SEL);
|
||||
@@ -21,9 +21,34 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.js';
|
||||
import { ICON_LIST_CHECKS } from './icons.js';
|
||||
import { t } from './i18n.ts';
|
||||
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
|
||||
import { ICON_LIST_CHECKS } from './icons.ts';
|
||||
|
||||
export interface BulkAction {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
style?: string;
|
||||
confirm?: string;
|
||||
handler: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CardItem {
|
||||
key: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface CardSectionOpts {
|
||||
titleKey: string;
|
||||
gridClass: string;
|
||||
addCardOnclick?: string;
|
||||
keyAttr?: string;
|
||||
headerExtra?: string;
|
||||
collapsible?: boolean;
|
||||
emptyKey?: string;
|
||||
bulkActions?: BulkAction[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'sections_collapsed';
|
||||
const ORDER_PREFIX = 'card_order_';
|
||||
@@ -32,25 +57,34 @@ const SCROLL_EDGE = 60;
|
||||
const SCROLL_SPEED = 12;
|
||||
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
function _getCollapsedMap() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
|
||||
function _getCollapsedMap(): Record<string, boolean> {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) as string) || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class CardSection {
|
||||
|
||||
/**
|
||||
* @param {string} sectionKey Unique key for localStorage persistence
|
||||
* @param {object} opts
|
||||
* @param {string} opts.titleKey i18n key for the section title
|
||||
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid'
|
||||
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
|
||||
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
|
||||
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
|
||||
* @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items
|
||||
* @param {Array} [opts.bulkActions] Bulk action descriptors: [{ key, labelKey, style?, confirm?, handler }]
|
||||
*/
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }) {
|
||||
sectionKey: string;
|
||||
titleKey: string;
|
||||
gridClass: string;
|
||||
addCardOnclick: string;
|
||||
keyAttr: string;
|
||||
headerExtra: string;
|
||||
collapsible: boolean;
|
||||
emptyKey: string;
|
||||
bulkActions: BulkAction[] | null;
|
||||
_filterValue: string;
|
||||
_lastItems: CardItem[] | null;
|
||||
_dragState: any;
|
||||
_dragBound: boolean;
|
||||
_selecting: boolean;
|
||||
_selected: Set<string>;
|
||||
_lastClickedKey: string | null;
|
||||
_escHandler: ((e: KeyboardEvent) => void) | null;
|
||||
_pendingReconcile: CardItem[] | null;
|
||||
_animated: boolean;
|
||||
|
||||
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
|
||||
this.sectionKey = sectionKey;
|
||||
this.titleKey = titleKey;
|
||||
this.gridClass = gridClass;
|
||||
@@ -68,6 +102,9 @@ export class CardSection {
|
||||
this._selecting = false;
|
||||
this._selected = new Set();
|
||||
this._lastClickedKey = null;
|
||||
this._escHandler = null;
|
||||
this._pendingReconcile = null;
|
||||
this._animated = false;
|
||||
}
|
||||
|
||||
/** True if this section's DOM element exists (i.e. not the first render). */
|
||||
@@ -75,11 +112,8 @@ export class CardSection {
|
||||
return !!document.querySelector(`[data-card-section="${this.sectionKey}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns section HTML string for initial innerHTML building.
|
||||
* @param {Array<{key: string, html: string}>} items
|
||||
*/
|
||||
render(items) {
|
||||
/** Returns section HTML string for initial innerHTML building. */
|
||||
render(items: CardItem[]) {
|
||||
this._lastItems = items;
|
||||
this._dragBound = false; // DOM will be recreated → need to re-init drag
|
||||
const count = items.length;
|
||||
@@ -122,19 +156,19 @@ export class CardSection {
|
||||
/** Attach event listeners after innerHTML is set. */
|
||||
bind() {
|
||||
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`) as HTMLInputElement | null;
|
||||
if (!header || !content) return;
|
||||
|
||||
if (this.collapsible) {
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.cs-filter-wrap') || e.target.closest('.cs-header-extra')) return;
|
||||
if ((e.target as HTMLElement).closest('.cs-filter-wrap') || (e.target as HTMLElement).closest('.cs-header-extra')) return;
|
||||
this._toggleCollapse(header, content);
|
||||
});
|
||||
}
|
||||
|
||||
if (filterInput) {
|
||||
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`);
|
||||
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
const updateResetVisibility = () => {
|
||||
if (resetBtn) resetBtn.style.display = filterInput.value ? '' : 'none';
|
||||
};
|
||||
@@ -142,7 +176,7 @@ export class CardSection {
|
||||
filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
if (resetBtn) resetBtn.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
|
||||
let timer = null;
|
||||
let timer: any = null;
|
||||
filterInput.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
updateResetVisibility();
|
||||
@@ -151,7 +185,7 @@ export class CardSection {
|
||||
this._applyFilter(content, this._filterValue);
|
||||
}, 150);
|
||||
});
|
||||
filterInput.addEventListener('keydown', (e) => {
|
||||
filterInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (filterInput.value) {
|
||||
e.stopPropagation();
|
||||
@@ -196,12 +230,12 @@ export class CardSection {
|
||||
|
||||
// Card click delegation for selection
|
||||
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
|
||||
content.addEventListener('click', (e) => {
|
||||
content.addEventListener('click', (e: MouseEvent) => {
|
||||
if (!this.keyAttr) return;
|
||||
const card = e.target.closest(`[${this.keyAttr}]`);
|
||||
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
|
||||
if (!card) return;
|
||||
// Don't hijack clicks on buttons, links, inputs inside cards
|
||||
if (e.target.closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
|
||||
|
||||
// Auto-enter selection mode on Ctrl/Cmd+Click
|
||||
if (!this._selecting && (e.ctrlKey || e.metaKey)) {
|
||||
@@ -221,7 +255,7 @@ export class CardSection {
|
||||
});
|
||||
|
||||
// Escape to exit selection mode
|
||||
this._escHandler = (e) => {
|
||||
this._escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && this._selecting) {
|
||||
this.exitSelectionMode();
|
||||
}
|
||||
@@ -245,30 +279,26 @@ export class CardSection {
|
||||
this._cacheSearchText(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental DOM diff — update cards in-place without rebuilding the section.
|
||||
* @param {Array<{key: string, html: string}>} items
|
||||
* @returns {{added: Set<string>, replaced: Set<string>, removed: Set<string>}}
|
||||
*/
|
||||
reconcile(items) {
|
||||
/** Incremental DOM diff — update cards in-place without rebuilding the section. */
|
||||
reconcile(items: CardItem[]) {
|
||||
// Skip DOM mutations while a drag is in progress — would destroy drag state
|
||||
if (this._dragState) {
|
||||
this._pendingReconcile = items;
|
||||
return { added: new Set(), replaced: new Set(), removed: new Set() };
|
||||
}
|
||||
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
if (!content) return { added: new Set(), replaced: new Set(), removed: new Set() };
|
||||
|
||||
this._lastItems = items;
|
||||
|
||||
// Update count badge (will be refined by _applyFilter if a filter is active)
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
if (countEl && !this._filterValue) countEl.textContent = items.length;
|
||||
if (countEl && !this._filterValue) countEl.textContent = String(items.length);
|
||||
|
||||
// Show/hide empty state
|
||||
if (this.emptyKey) {
|
||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
|
||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
if (items.length === 0) {
|
||||
if (!emptyEl) {
|
||||
emptyEl = document.createElement('div');
|
||||
@@ -287,9 +317,9 @@ export class CardSection {
|
||||
|
||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
const added = new Set();
|
||||
const replaced = new Set();
|
||||
const removed = new Set();
|
||||
const added = new Set<string>();
|
||||
const replaced = new Set<string>();
|
||||
const removed = new Set<string>();
|
||||
|
||||
// Process existing cards: remove or update
|
||||
if (this.keyAttr) {
|
||||
@@ -298,16 +328,16 @@ export class CardSection {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (!newMap.has(key)) {
|
||||
card.remove();
|
||||
removed.add(key);
|
||||
removed.add(key!);
|
||||
} else {
|
||||
const newHtml = newMap.get(key);
|
||||
if (card._csHtml !== newHtml) {
|
||||
if ((card as any)._csHtml !== newHtml) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = newHtml;
|
||||
const newEl = tmp.firstElementChild;
|
||||
const newEl = tmp.firstElementChild as any;
|
||||
newEl._csHtml = newHtml;
|
||||
card.replaceWith(newEl);
|
||||
replaced.add(key);
|
||||
replaced.add(key!);
|
||||
}
|
||||
// else: unchanged — skip
|
||||
}
|
||||
@@ -321,7 +351,7 @@ export class CardSection {
|
||||
if (!existingKeys.has(key)) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const newEl = tmp.firstElementChild;
|
||||
const newEl = tmp.firstElementChild as any;
|
||||
newEl._csHtml = html;
|
||||
if (addCard) content.insertBefore(newEl, addCard);
|
||||
else content.appendChild(newEl);
|
||||
@@ -334,7 +364,7 @@ export class CardSection {
|
||||
if (added.size > 0 && this.keyAttr && !_reducedMotion.matches) {
|
||||
let delay = 0;
|
||||
for (const key of added) {
|
||||
const card = content.querySelector(`[${this.keyAttr}="${key}"]`);
|
||||
const card = content.querySelector(`[${this.keyAttr}="${key}"]`) as HTMLElement | null;
|
||||
if (card) {
|
||||
card.style.animationDelay = `${delay}ms`;
|
||||
card.classList.add('card-enter');
|
||||
@@ -379,14 +409,14 @@ export class CardSection {
|
||||
}
|
||||
|
||||
/** Bind an array of CardSection instances. */
|
||||
static bindAll(sections) {
|
||||
static bindAll(sections: CardSection[]) {
|
||||
for (const s of sections) s.bind();
|
||||
}
|
||||
|
||||
/** Programmatically expand this section if collapsed. */
|
||||
expand() {
|
||||
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
if (!header || !content) return;
|
||||
const map = _getCollapsedMap();
|
||||
if (map[this.sectionKey]) {
|
||||
@@ -395,7 +425,7 @@ export class CardSection {
|
||||
content.style.display = '';
|
||||
const section = header.closest('[data-card-section]');
|
||||
if (section) section.classList.remove('cs-collapsed');
|
||||
const chevron = header.querySelector('.cs-chevron');
|
||||
const chevron = header.querySelector('.cs-chevron') as HTMLElement | null;
|
||||
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
||||
}
|
||||
}
|
||||
@@ -404,16 +434,16 @@ export class CardSection {
|
||||
* Reorder items array according to saved drag order.
|
||||
* Call before render() / reconcile().
|
||||
*/
|
||||
applySortOrder(items) {
|
||||
applySortOrder(items: CardItem[]) {
|
||||
if (!this.keyAttr) return items;
|
||||
const order = this._getSavedOrder();
|
||||
if (!order.length) return items;
|
||||
const orderMap = new Map(order.map((key, idx) => [key, idx]));
|
||||
const orderMap = new Map(order.map((key: string, idx: number) => [key, idx]));
|
||||
const sorted = [...items];
|
||||
sorted.sort((a, b) => {
|
||||
const ia = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity;
|
||||
const ib = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity;
|
||||
if (ia !== ib) return ia - ib;
|
||||
if (ia !== ib) return (ia as number) - (ib as number);
|
||||
return 0; // preserve original order for unranked items
|
||||
});
|
||||
return sorted;
|
||||
@@ -430,7 +460,7 @@ export class CardSection {
|
||||
if (section) section.classList.add('cs-selecting');
|
||||
const btn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`);
|
||||
if (btn) btn.classList.add('active');
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
if (content) this._injectCheckboxes(content);
|
||||
showBulkToolbar(this);
|
||||
}
|
||||
@@ -453,16 +483,16 @@ export class CardSection {
|
||||
hideBulkToolbar();
|
||||
}
|
||||
|
||||
_toggleSelect(key) {
|
||||
_toggleSelect(key: string) {
|
||||
if (this._selected.has(key)) this._selected.delete(key);
|
||||
else this._selected.add(key);
|
||||
this._applySelectionVisuals();
|
||||
updateBulkToolbar();
|
||||
}
|
||||
|
||||
_selectRange(content, fromKey, toKey) {
|
||||
const cards = [...content.querySelectorAll(`[${this.keyAttr}]`)];
|
||||
const keys = cards.map(c => c.getAttribute(this.keyAttr));
|
||||
_selectRange(content: HTMLElement, fromKey: string, toKey: string) {
|
||||
const cards = [...content.querySelectorAll(`[${this.keyAttr}]`)] as HTMLElement[];
|
||||
const keys = cards.map(c => c.getAttribute(this.keyAttr)!);
|
||||
const fromIdx = keys.indexOf(fromKey);
|
||||
const toIdx = keys.indexOf(toKey);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
@@ -481,9 +511,9 @@ export class CardSection {
|
||||
selectAll() {
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
if (!content || !this.keyAttr) return;
|
||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||
(content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>).forEach(card => {
|
||||
if (card.style.display !== 'none') {
|
||||
this._selected.add(card.getAttribute(this.keyAttr));
|
||||
this._selected.add(card.getAttribute(this.keyAttr)!);
|
||||
}
|
||||
});
|
||||
this._applySelectionVisuals();
|
||||
@@ -500,7 +530,7 @@ export class CardSection {
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
if (!content || !this.keyAttr) return 0;
|
||||
let count = 0;
|
||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||
(content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>).forEach(card => {
|
||||
if (card.style.display !== 'none') count++;
|
||||
});
|
||||
return count;
|
||||
@@ -511,24 +541,24 @@ export class CardSection {
|
||||
if (!content || !this.keyAttr) return;
|
||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
const selected = this._selected.has(key);
|
||||
const selected = this._selected.has(key!);
|
||||
card.classList.toggle('card-selected', selected);
|
||||
const cb = card.querySelector('.card-bulk-check');
|
||||
const cb = card.querySelector('.card-bulk-check') as HTMLInputElement | null;
|
||||
if (cb) cb.checked = selected;
|
||||
});
|
||||
}
|
||||
|
||||
_injectCheckboxes(content) {
|
||||
_injectCheckboxes(content: HTMLElement) {
|
||||
if (!this.keyAttr) return;
|
||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||
if (card.querySelector('.card-bulk-check')) return;
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'card-bulk-check';
|
||||
cb.checked = this._selected.has(card.getAttribute(this.keyAttr));
|
||||
cb.addEventListener('click', (e) => {
|
||||
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
|
||||
cb.addEventListener('click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
const key = card.getAttribute(this.keyAttr)!;
|
||||
if (e.shiftKey && this._lastClickedKey) {
|
||||
this._selectRange(content, this._lastClickedKey, key);
|
||||
} else {
|
||||
@@ -544,22 +574,22 @@ export class CardSection {
|
||||
}
|
||||
|
||||
_getSavedOrder() {
|
||||
try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; }
|
||||
try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey) as string) || []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
_saveOrder(keys) {
|
||||
_saveOrder(keys: (string | null)[]) {
|
||||
localStorage.setItem(ORDER_PREFIX + this.sectionKey, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
// ── private ──
|
||||
|
||||
_animateEntrance(content) {
|
||||
_animateEntrance(content: HTMLElement) {
|
||||
if (this._animated) return;
|
||||
this._animated = true;
|
||||
if (_reducedMotion.matches) return;
|
||||
const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)';
|
||||
const cards = content.querySelectorAll(selector);
|
||||
const cards = content.querySelectorAll(selector) as NodeListOf<HTMLElement>;
|
||||
cards.forEach((card, i) => {
|
||||
card.style.animationDelay = `${i * 30}ms`;
|
||||
card.classList.add('card-enter');
|
||||
@@ -567,25 +597,25 @@ export class CardSection {
|
||||
});
|
||||
}
|
||||
|
||||
_tagCards(content) {
|
||||
_tagCards(content: HTMLElement) {
|
||||
if (!this.keyAttr || !this._lastItems) return;
|
||||
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
|
||||
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
|
||||
cards.forEach(card => {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (htmlMap.has(key)) card._csHtml = htmlMap.get(key);
|
||||
if (htmlMap.has(key)) (card as any)._csHtml = htmlMap.get(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Cache each card's lowercased text content in data-search for fast filtering. */
|
||||
_cacheSearchText(content) {
|
||||
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)');
|
||||
_cacheSearchText(content: HTMLElement) {
|
||||
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)') as NodeListOf<HTMLElement>;
|
||||
cards.forEach(card => {
|
||||
card.dataset.search = card.textContent.toLowerCase();
|
||||
card.dataset.search = card.textContent!.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
_toggleCollapse(header, content) {
|
||||
_toggleCollapse(header: Element, content: HTMLElement) {
|
||||
const map = _getCollapsedMap();
|
||||
const nowCollapsed = !map[this.sectionKey];
|
||||
map[this.sectionKey] = nowCollapsed;
|
||||
@@ -594,11 +624,11 @@ export class CardSection {
|
||||
const section = header.closest('[data-card-section]');
|
||||
if (section) section.classList.toggle('cs-collapsed', nowCollapsed);
|
||||
|
||||
const chevron = header.querySelector('.cs-chevron');
|
||||
const chevron = header.querySelector('.cs-chevron') as HTMLElement | null;
|
||||
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
||||
|
||||
// Cancel any running animation on this content
|
||||
if (content._csAnim) { content._csAnim.cancel(); content._csAnim = null; }
|
||||
if ((content as any)._csAnim) { (content as any)._csAnim.cancel(); (content as any)._csAnim = null; }
|
||||
|
||||
// Skip animation when user prefers reduced motion
|
||||
if (_reducedMotion.matches) {
|
||||
@@ -613,11 +643,11 @@ export class CardSection {
|
||||
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: 200, easing: 'ease-in-out' }
|
||||
);
|
||||
content._csAnim = anim;
|
||||
(content as any)._csAnim = anim;
|
||||
anim.onfinish = () => {
|
||||
content.style.display = 'none';
|
||||
content.style.overflow = '';
|
||||
content._csAnim = null;
|
||||
(content as any)._csAnim = null;
|
||||
};
|
||||
} else {
|
||||
// Intentional forced layout: reading scrollHeight after setting display=''
|
||||
@@ -629,17 +659,17 @@ export class CardSection {
|
||||
[{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
|
||||
{ duration: 200, easing: 'ease-in-out' }
|
||||
);
|
||||
content._csAnim = anim;
|
||||
(content as any)._csAnim = anim;
|
||||
anim.onfinish = () => {
|
||||
content.style.overflow = '';
|
||||
content._csAnim = null;
|
||||
(content as any)._csAnim = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_applyFilter(content, query) {
|
||||
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)');
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
_applyFilter(content: HTMLElement, query: string) {
|
||||
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)') as NodeListOf<HTMLElement>;
|
||||
const addCard = content.querySelector('.cs-add-card') as HTMLElement | null;
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
const total = cards.length;
|
||||
|
||||
@@ -647,7 +677,7 @@ export class CardSection {
|
||||
content.classList.remove('cs-filtering');
|
||||
cards.forEach(card => { card.style.display = ''; });
|
||||
if (addCard) addCard.style.display = '';
|
||||
if (countEl) countEl.textContent = total;
|
||||
if (countEl) countEl.textContent = String(total);
|
||||
return;
|
||||
}
|
||||
content.classList.add('cs-filtering');
|
||||
@@ -658,7 +688,7 @@ export class CardSection {
|
||||
let visible = 0;
|
||||
|
||||
cards.forEach(card => {
|
||||
const text = card.dataset.search || card.textContent.toLowerCase();
|
||||
const text = card.dataset.search || card.textContent!.toLowerCase();
|
||||
// Each group must have at least one matching term (AND of ORs)
|
||||
const match = groups.every(orTerms => orTerms.some(term => text.includes(term)));
|
||||
card.style.display = match ? '' : 'none';
|
||||
@@ -671,7 +701,7 @@ export class CardSection {
|
||||
|
||||
// ── drag-and-drop reordering ──
|
||||
|
||||
_injectDragHandles(content) {
|
||||
_injectDragHandles(content: HTMLElement) {
|
||||
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
|
||||
cards.forEach(card => {
|
||||
if (card.querySelector('.card-drag-handle')) return;
|
||||
@@ -683,15 +713,15 @@ export class CardSection {
|
||||
});
|
||||
}
|
||||
|
||||
_initDrag(content) {
|
||||
_initDrag(content: HTMLElement) {
|
||||
if (this._dragBound) return;
|
||||
this._dragBound = true;
|
||||
|
||||
content.addEventListener('pointerdown', (e) => {
|
||||
content.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
if (this._filterValue) return;
|
||||
const handle = e.target.closest('.card-drag-handle');
|
||||
const handle = (e.target as HTMLElement).closest('.card-drag-handle');
|
||||
if (!handle) return;
|
||||
const card = handle.closest(`[${this.keyAttr}]`);
|
||||
const card = handle.closest(`[${this.keyAttr}]`) as HTMLElement | null;
|
||||
if (!card) return;
|
||||
|
||||
e.preventDefault();
|
||||
@@ -706,7 +736,7 @@ export class CardSection {
|
||||
scrollRaf: null,
|
||||
};
|
||||
|
||||
const onMove = (ev) => this._onDragMove(ev);
|
||||
const onMove = (ev: PointerEvent) => this._onDragMove(ev);
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', cleanup);
|
||||
@@ -719,7 +749,7 @@ export class CardSection {
|
||||
});
|
||||
}
|
||||
|
||||
_onDragMove(e) {
|
||||
_onDragMove(e: PointerEvent) {
|
||||
const ds = this._dragState;
|
||||
if (!ds) return;
|
||||
|
||||
@@ -758,12 +788,12 @@ export class CardSection {
|
||||
this._autoScroll(e.clientY, ds);
|
||||
}
|
||||
|
||||
_startDrag(ds, e) {
|
||||
_startDrag(ds: any, e: PointerEvent) {
|
||||
ds.started = true;
|
||||
const rect = ds.card.getBoundingClientRect();
|
||||
|
||||
// Clone for visual drag
|
||||
const clone = ds.card.cloneNode(true);
|
||||
const clone = ds.card.cloneNode(true) as HTMLElement;
|
||||
clone.className = ds.card.className + ' card-drag-clone';
|
||||
clone.style.width = rect.width + 'px';
|
||||
clone.style.height = rect.height + 'px';
|
||||
@@ -820,8 +850,8 @@ export class CardSection {
|
||||
* Only triggers placeholder move when cursor is inside a card — dragging
|
||||
* over gaps keeps the placeholder in its last position.
|
||||
*/
|
||||
_hitTestCard(x, y, ds) {
|
||||
const cards = ds.content.querySelectorAll(`[${this.keyAttr}]`);
|
||||
_hitTestCard(x: number, y: number, ds: any): HTMLElement | null {
|
||||
const cards = ds.content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>;
|
||||
for (const card of cards) {
|
||||
if (card === ds.card) continue;
|
||||
if (card.style.display === 'none') continue;
|
||||
@@ -833,12 +863,12 @@ export class CardSection {
|
||||
return null;
|
||||
}
|
||||
|
||||
_readDomOrder(content) {
|
||||
_readDomOrder(content: HTMLElement) {
|
||||
return [...content.querySelectorAll(`[${this.keyAttr}]`)]
|
||||
.map(el => el.getAttribute(this.keyAttr));
|
||||
}
|
||||
|
||||
_autoScroll(clientY, ds) {
|
||||
_autoScroll(clientY: number, ds: any) {
|
||||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||
const vp = window.innerHeight;
|
||||
let speed = 0;
|
||||
@@ -19,11 +19,11 @@
|
||||
* @param {number} [opts.maxHwFps] hardware max FPS — draws a dashed reference line
|
||||
* @returns {Chart|null}
|
||||
*/
|
||||
export function createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, opts = {}) {
|
||||
export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return null;
|
||||
|
||||
const datasets = [
|
||||
const datasets: any[] = [
|
||||
{
|
||||
data: [...actualHistory],
|
||||
borderColor: '#2196F3',
|
||||
@@ -2,7 +2,7 @@
|
||||
* Reusable color-picker popover.
|
||||
*
|
||||
* Usage:
|
||||
* import { createColorPicker } from '../core/color-picker.js';
|
||||
* import { createColorPicker } from '../core/color-picker.ts';
|
||||
* const html = createColorPicker({
|
||||
* id: 'my-picker',
|
||||
* currentColor: '#4CAF50',
|
||||
@@ -17,7 +17,7 @@
|
||||
* Call `closeAllColorPickers()` to dismiss any open popover.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
const PRESETS = [
|
||||
'#4CAF50', '#7C4DFF', '#FF6D00',
|
||||
@@ -28,7 +28,7 @@ const PRESETS = [
|
||||
/**
|
||||
* Build the HTML string for a color-picker widget.
|
||||
*/
|
||||
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) {
|
||||
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }: { id: string; currentColor: string; onPick?: string; anchor?: string; showReset?: boolean; resetColor?: string }) {
|
||||
const dots = PRESETS.map(c => {
|
||||
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
|
||||
@@ -56,14 +56,14 @@ export function createColorPicker({ id, currentColor, onPick, anchor = 'right',
|
||||
// -- Global helpers called from onclick attributes --
|
||||
|
||||
// Merge any callbacks pre-registered before this module loaded (e.g. accent picker in index.html)
|
||||
const _callbacks = Object.assign({}, window._cpCallbacks || {});
|
||||
const _callbacks: Record<string, (hex: string) => void> = Object.assign({}, window._cpCallbacks || {});
|
||||
|
||||
/** Register the callback for a picker id. */
|
||||
export function registerColorPicker(id, callback) {
|
||||
export function registerColorPicker(id: string, callback: (hex: string) => void) {
|
||||
_callbacks[id] = callback;
|
||||
}
|
||||
|
||||
function _rgbToHex(rgb) {
|
||||
function _rgbToHex(rgb: string) {
|
||||
const m = rgb.match(/\d+/g);
|
||||
if (!m || m.length < 3) return rgb;
|
||||
return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
|
||||
@@ -71,9 +71,9 @@ function _rgbToHex(rgb) {
|
||||
|
||||
window._cpToggle = function (id) {
|
||||
// Close all other pickers first (and drop their card elevation)
|
||||
document.querySelectorAll('.color-picker-popover').forEach(p => {
|
||||
document.querySelectorAll('.color-picker-popover').forEach((p: Element) => {
|
||||
if (p.id !== `cp-pop-${id}`) {
|
||||
_cpClosePopover(p);
|
||||
_cpClosePopover(p as HTMLElement);
|
||||
}
|
||||
});
|
||||
const pop = document.getElementById(`cp-pop-${id}`);
|
||||
@@ -92,8 +92,8 @@ window._cpToggle = function (id) {
|
||||
// to avoid any parent overflow/containment/positioning issues.
|
||||
const isMobile = window.innerWidth <= 768 || ('ontouchstart' in window);
|
||||
if (isMobile && pop.parentElement !== document.body) {
|
||||
pop._cpOrigParent = pop.parentElement;
|
||||
pop._cpOrigNext = pop.nextSibling;
|
||||
(pop as any)._cpOrigParent = pop.parentElement;
|
||||
(pop as any)._cpOrigNext = pop.nextSibling;
|
||||
document.body.appendChild(pop);
|
||||
}
|
||||
if (isMobile) {
|
||||
@@ -113,14 +113,15 @@ window._cpToggle = function (id) {
|
||||
// Mark active dot
|
||||
const swatch = document.getElementById(`cp-swatch-${id}`);
|
||||
const cur = swatch ? (_rgbToHex(swatch.style.backgroundColor) || swatch.style.background) : '';
|
||||
pop.querySelectorAll('.color-picker-dot').forEach(d => {
|
||||
const dHex = _rgbToHex(d.style.backgroundColor || d.style.background);
|
||||
d.classList.toggle('active', dHex.toLowerCase() === cur.toLowerCase());
|
||||
pop.querySelectorAll('.color-picker-dot').forEach((d: Element) => {
|
||||
const el = d as HTMLElement;
|
||||
const dHex = _rgbToHex(el.style.backgroundColor || el.style.background);
|
||||
el.classList.toggle('active', dHex.toLowerCase() === cur.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
/** Reset popover positioning and close. */
|
||||
function _cpClosePopover(pop) {
|
||||
function _cpClosePopover(pop: HTMLElement) {
|
||||
pop.style.display = 'none';
|
||||
if (pop.classList.contains('cp-fixed')) {
|
||||
pop.classList.remove('cp-fixed');
|
||||
@@ -135,10 +136,10 @@ function _cpClosePopover(pop) {
|
||||
pop.style.width = '';
|
||||
pop.style.zIndex = '';
|
||||
// Return popover to its original parent
|
||||
if (pop._cpOrigParent) {
|
||||
pop._cpOrigParent.insertBefore(pop, pop._cpOrigNext || null);
|
||||
delete pop._cpOrigParent;
|
||||
delete pop._cpOrigNext;
|
||||
if ((pop as any)._cpOrigParent) {
|
||||
(pop as any)._cpOrigParent.insertBefore(pop, (pop as any)._cpOrigNext || null);
|
||||
delete (pop as any)._cpOrigParent;
|
||||
delete (pop as any)._cpOrigNext;
|
||||
}
|
||||
}
|
||||
const card = pop.closest('.card, .template-card');
|
||||
@@ -153,14 +154,15 @@ window._cpPick = function (id, hex) {
|
||||
const swatch = document.getElementById(`cp-swatch-${id}`);
|
||||
if (swatch) swatch.style.background = hex;
|
||||
// Update native input
|
||||
const native = document.getElementById(`cp-native-${id}`);
|
||||
const native = document.getElementById(`cp-native-${id}`) as HTMLInputElement | null;
|
||||
if (native) native.value = hex;
|
||||
// Mark active dot and close
|
||||
const pop = document.getElementById(`cp-pop-${id}`);
|
||||
if (pop) {
|
||||
pop.querySelectorAll('.color-picker-dot').forEach(d => {
|
||||
const dHex = _rgbToHex(d.style.backgroundColor || d.style.background);
|
||||
d.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase());
|
||||
pop.querySelectorAll('.color-picker-dot').forEach((d: Element) => {
|
||||
const el = d as HTMLElement;
|
||||
const dHex = _rgbToHex(el.style.backgroundColor || el.style.background);
|
||||
el.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase());
|
||||
});
|
||||
_cpClosePopover(pop);
|
||||
}
|
||||
@@ -168,7 +170,7 @@ window._cpPick = function (id, hex) {
|
||||
if (_callbacks[id]) _callbacks[id](hex);
|
||||
};
|
||||
|
||||
window._cpReset = function (id, resetColor) {
|
||||
window._cpReset = function (id: string, resetColor: string) {
|
||||
// Reset swatch to neutral color
|
||||
const swatch = document.getElementById(`cp-swatch-${id}`);
|
||||
if (swatch) swatch.style.background = resetColor;
|
||||
@@ -183,7 +185,7 @@ window._cpReset = function (id, resetColor) {
|
||||
};
|
||||
|
||||
export function closeAllColorPickers() {
|
||||
document.querySelectorAll('.color-picker-popover').forEach(p => _cpClosePopover(p));
|
||||
document.querySelectorAll('.color-picker-popover').forEach(p => _cpClosePopover(p as HTMLElement));
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
@@ -2,21 +2,21 @@
|
||||
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from './api.js';
|
||||
import { t } from './i18n.js';
|
||||
import { navigateToCard } from './navigation.js';
|
||||
import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { navigateToCard } from './navigation.ts';
|
||||
import {
|
||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
|
||||
} from './icons.js';
|
||||
import { getCardColor } from './card-colors.js';
|
||||
import { graphNavigateToNode } from '../features/graph-editor.js';
|
||||
import { showToast } from './ui.js';
|
||||
} from './icons.ts';
|
||||
import { getCardColor } from './card-colors.ts';
|
||||
import { graphNavigateToNode } from '../features/graph-editor.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
|
||||
let _isOpen = false;
|
||||
let _items = [];
|
||||
let _filtered = [];
|
||||
let _items: any[] = [];
|
||||
let _filtered: any[] = [];
|
||||
let _selectedIdx = 0;
|
||||
let _loading = false;
|
||||
|
||||
@@ -28,12 +28,12 @@ const _streamSubTab = {
|
||||
static_image: { sub: 'static_image', section: 'static-streams' },
|
||||
};
|
||||
|
||||
function _mapEntities(data, mapFn) {
|
||||
function _mapEntities(data: any, mapFn: (item: any) => any) {
|
||||
if (Array.isArray(data)) return data.map(mapFn).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function _buildItems(results, states = {}) {
|
||||
function _buildItems(results: any[], states: any = {}) {
|
||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
|
||||
const items = [];
|
||||
|
||||
@@ -193,12 +193,12 @@ async function _fetchAllEntities() {
|
||||
const [statesData, ...results] = await Promise.all([
|
||||
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||
.then(r => r.ok ? r.json() : {})
|
||||
.then(data => data.states || {})
|
||||
.then((data: any) => data.states || {})
|
||||
.catch(() => ({})),
|
||||
..._responseKeys.map(([ep, key]) =>
|
||||
fetchWithAuth(ep, { retry: false, timeout: 5000 })
|
||||
.then(r => r.ok ? r.json() : {})
|
||||
.then(data => data[key] || [])
|
||||
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
|
||||
.then((r: any) => r.ok ? r.json() : {})
|
||||
.then((data: any) => data[key as string] || [])
|
||||
.catch(() => [])),
|
||||
]);
|
||||
return _buildItems(results, statesData);
|
||||
@@ -217,7 +217,7 @@ const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
|
||||
|
||||
// ─── Filtering ───
|
||||
|
||||
function _filterItems(query) {
|
||||
function _filterItems(query: string) {
|
||||
let result = _items;
|
||||
if (query) {
|
||||
const lower = query.toLowerCase();
|
||||
@@ -280,7 +280,7 @@ function _render() {
|
||||
_scrollActive(results);
|
||||
}
|
||||
|
||||
function _scrollActive(container) {
|
||||
function _scrollActive(container: HTMLElement) {
|
||||
const active = container.querySelector('.cp-active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
@@ -293,8 +293,8 @@ export async function openCommandPalette() {
|
||||
_isOpen = true;
|
||||
_selectedIdx = 0;
|
||||
|
||||
const overlay = document.getElementById('command-palette');
|
||||
const input = document.getElementById('cp-input');
|
||||
const overlay = document.getElementById('command-palette')!;
|
||||
const input = document.getElementById('cp-input') as HTMLInputElement;
|
||||
overlay.style.display = '';
|
||||
document.body.classList.add('modal-open');
|
||||
input.value = '';
|
||||
@@ -316,7 +316,7 @@ export async function openCommandPalette() {
|
||||
export function closeCommandPalette() {
|
||||
if (!_isOpen) return;
|
||||
_isOpen = false;
|
||||
const overlay = document.getElementById('command-palette');
|
||||
const overlay = document.getElementById('command-palette')!;
|
||||
overlay.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
_items = [];
|
||||
@@ -326,13 +326,13 @@ export function closeCommandPalette() {
|
||||
// ─── Event handlers ───
|
||||
|
||||
function _onInput() {
|
||||
const input = document.getElementById('cp-input');
|
||||
const input = document.getElementById('cp-input') as HTMLInputElement;
|
||||
_filtered = _filterItems(input.value.trim());
|
||||
_selectedIdx = 0;
|
||||
_render();
|
||||
}
|
||||
|
||||
function _onKeydown(e) {
|
||||
function _onKeydown(e: KeyboardEvent) {
|
||||
if (!_isOpen) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
@@ -352,14 +352,14 @@ function _onKeydown(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function _onClick(e) {
|
||||
const row = e.target.closest('.cp-result');
|
||||
function _onClick(e: Event) {
|
||||
const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
|
||||
if (row) {
|
||||
_selectedIdx = parseInt(row.dataset.cpIdx, 10);
|
||||
_selectCurrent();
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('cp-backdrop')) {
|
||||
if ((e.target as HTMLElement).classList.contains('cp-backdrop')) {
|
||||
closeCommandPalette();
|
||||
}
|
||||
}
|
||||
@@ -380,7 +380,7 @@ function _selectCurrent() {
|
||||
const entityId = item.nav[4]; // last element is always entity ID
|
||||
if (graphNavigateToNode(entityId)) return;
|
||||
}
|
||||
navigateToCard(...item.nav);
|
||||
navigateToCard(...(item.nav as [string, string | null, string | null, string, string]));
|
||||
}
|
||||
|
||||
// ─── Initialization ───
|
||||
@@ -389,8 +389,8 @@ export function initCommandPalette() {
|
||||
const overlay = document.getElementById('command-palette');
|
||||
if (!overlay) return;
|
||||
|
||||
const input = document.getElementById('cp-input');
|
||||
const input = document.getElementById('cp-input')!;
|
||||
input.addEventListener('input', _onInput);
|
||||
input.addEventListener('keydown', _onKeydown);
|
||||
input.addEventListener('keydown', _onKeydown as EventListener);
|
||||
overlay.addEventListener('click', _onClick);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
syncClocksCache, automationsCacheObj, scenePresetsCache,
|
||||
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
||||
patternTemplatesCache,
|
||||
} from './state.js';
|
||||
} from './state.ts';
|
||||
|
||||
/** Maps entity_type string from the server event to its DataCache instance. */
|
||||
const ENTITY_CACHE_MAP = {
|
||||
@@ -70,7 +70,7 @@ function _invalidateAndReload(entityType) {
|
||||
clearTimeout(_loaderTimers[loader]);
|
||||
_loaderTimers[loader] = setTimeout(() => {
|
||||
delete _loaderTimers[loader];
|
||||
if (typeof window[loader] === 'function') window[loader]();
|
||||
if (typeof (window as any)[loader] === 'function') (window as any)[loader]();
|
||||
}, _LOADER_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Command-palette style entity selector.
|
||||
*
|
||||
* Usage:
|
||||
* import { EntityPalette, EntitySelect } from '../core/entity-palette.js';
|
||||
* import { EntityPalette, EntitySelect } from '../core/entity-palette.ts';
|
||||
*
|
||||
* // Direct use (promise-based):
|
||||
* const value = await EntityPalette.pick({
|
||||
@@ -24,18 +24,44 @@
|
||||
* Call sel.refresh() after repopulating the <select>, sel.destroy() to remove.
|
||||
*/
|
||||
|
||||
import { ICON_SEARCH } from './icons.js';
|
||||
import { ICON_SEARCH } from './icons.ts';
|
||||
import type { IconSelectItem } from './icon-select.ts';
|
||||
|
||||
// ── EntityPalette (singleton modal) ─────────────────────────
|
||||
|
||||
let _instance = null;
|
||||
export interface EntitySelectOpts {
|
||||
target: HTMLSelectElement;
|
||||
getItems: () => IconSelectItem[];
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
allowNone?: boolean;
|
||||
noneLabel?: string;
|
||||
}
|
||||
|
||||
interface EntityPalettePickOpts {
|
||||
items?: IconSelectItem[];
|
||||
current?: string;
|
||||
placeholder?: string;
|
||||
allowNone?: boolean;
|
||||
noneLabel?: string;
|
||||
}
|
||||
|
||||
let _instance: EntityPalette | null = null;
|
||||
|
||||
export class EntityPalette {
|
||||
/**
|
||||
* Open the palette and return a promise.
|
||||
* Resolves to selected value (string) or undefined if cancelled.
|
||||
*/
|
||||
static pick(opts) {
|
||||
_overlay: HTMLDivElement;
|
||||
_input: HTMLInputElement;
|
||||
_list: HTMLDivElement;
|
||||
_resolve: ((value: string | undefined) => void) | null;
|
||||
_items: IconSelectItem[];
|
||||
_filtered: (IconSelectItem & { _isNone?: boolean })[];
|
||||
_highlightIdx: number;
|
||||
_currentValue: string | undefined;
|
||||
_allowNone: boolean;
|
||||
_noneLabel: string;
|
||||
|
||||
/** Open the palette and return a promise. Resolves to selected value or undefined if cancelled. */
|
||||
static pick(opts: EntityPalettePickOpts) {
|
||||
if (!_instance) _instance = new EntityPalette();
|
||||
return _instance._pick(opts);
|
||||
}
|
||||
@@ -54,12 +80,15 @@ export class EntityPalette {
|
||||
`;
|
||||
document.body.appendChild(this._overlay);
|
||||
|
||||
this._input = this._overlay.querySelector('.entity-palette-input');
|
||||
this._list = this._overlay.querySelector('.entity-palette-list');
|
||||
this._input = this._overlay.querySelector('.entity-palette-input') as HTMLInputElement;
|
||||
this._list = this._overlay.querySelector('.entity-palette-list') as HTMLDivElement;
|
||||
this._resolve = null;
|
||||
this._items = [];
|
||||
this._filtered = [];
|
||||
this._highlightIdx = 0;
|
||||
this._currentValue = undefined;
|
||||
this._allowNone = false;
|
||||
this._noneLabel = '';
|
||||
|
||||
this._overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this._overlay) this._cancel();
|
||||
@@ -68,7 +97,7 @@ export class EntityPalette {
|
||||
this._input.addEventListener('keydown', (e) => this._onKeyDown(e));
|
||||
}
|
||||
|
||||
_pick({ items, current, placeholder, allowNone, noneLabel }) {
|
||||
_pick({ items, current, placeholder, allowNone, noneLabel }: EntityPalettePickOpts) {
|
||||
return new Promise(resolve => {
|
||||
this._resolve = resolve;
|
||||
this._items = items || [];
|
||||
@@ -86,8 +115,8 @@ export class EntityPalette {
|
||||
});
|
||||
}
|
||||
|
||||
_buildFullList() {
|
||||
const all = [];
|
||||
_buildFullList(): (IconSelectItem & { _isNone?: boolean })[] {
|
||||
const all: (IconSelectItem & { _isNone?: boolean })[] = [];
|
||||
if (this._allowNone) {
|
||||
all.push({ value: '', label: this._noneLabel || '—', icon: '', desc: '', _isNone: true });
|
||||
}
|
||||
@@ -131,7 +160,7 @@ export class EntityPalette {
|
||||
// Click handlers
|
||||
this._list.querySelectorAll('.entity-palette-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
this._select(this._filtered[parseInt(el.dataset.idx)]);
|
||||
this._select(this._filtered[parseInt((el as HTMLElement).dataset.idx!)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +169,7 @@ export class EntityPalette {
|
||||
if (hl) hl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
_onKeyDown(e) {
|
||||
_onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
|
||||
@@ -161,7 +190,7 @@ export class EntityPalette {
|
||||
}
|
||||
}
|
||||
|
||||
_select(item) {
|
||||
_select(item: any) {
|
||||
this._overlay.classList.remove('open');
|
||||
if (this._resolve) this._resolve(item.value);
|
||||
this._resolve = null;
|
||||
@@ -177,16 +206,16 @@ export class EntityPalette {
|
||||
// ── EntitySelect (wrapper around a <select>) ────────────────
|
||||
|
||||
export class EntitySelect {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {HTMLSelectElement} opts.target - the <select> to enhance
|
||||
* @param {Function} opts.getItems - () => Array<{value, label, icon?, desc?}>
|
||||
* @param {string} [opts.placeholder] - palette search placeholder
|
||||
* @param {Function} [opts.onChange] - called with (value) after selection
|
||||
* @param {boolean} [opts.allowNone] - show a "None" entry at the top
|
||||
* @param {string} [opts.noneLabel] - label for the None entry
|
||||
*/
|
||||
constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }) {
|
||||
_select: HTMLSelectElement;
|
||||
_getItems: () => IconSelectItem[];
|
||||
_placeholder: string;
|
||||
_onChange: ((value: string) => void) | null;
|
||||
_allowNone: boolean;
|
||||
_noneLabel: string;
|
||||
_items: IconSelectItem[];
|
||||
_trigger: HTMLButtonElement;
|
||||
|
||||
constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }: EntitySelectOpts) {
|
||||
this._select = target;
|
||||
this._getItems = getItems;
|
||||
this._placeholder = placeholder || '';
|
||||
@@ -203,7 +232,7 @@ export class EntitySelect {
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'entity-select-trigger';
|
||||
this._trigger.addEventListener('click', () => this._open());
|
||||
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||
this._select.parentNode!.insertBefore(this._trigger, this._select.nextSibling);
|
||||
|
||||
this._syncTrigger();
|
||||
}
|
||||
@@ -216,7 +245,7 @@ export class EntitySelect {
|
||||
placeholder: this._placeholder,
|
||||
allowNone: this._allowNone,
|
||||
noneLabel: this._noneLabel,
|
||||
});
|
||||
}) as string | undefined;
|
||||
if (value !== undefined) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
@@ -248,7 +277,7 @@ export class EntitySelect {
|
||||
}
|
||||
|
||||
/** Update the value programmatically (no change event). */
|
||||
setValue(value) {
|
||||
setValue(value: string) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
}
|
||||
@@ -9,10 +9,10 @@
|
||||
* server:device_health_changed — device online/offline status change
|
||||
*/
|
||||
|
||||
import { apiKey } from './state.js';
|
||||
import { apiKey } from './state.ts';
|
||||
|
||||
let _ws = null;
|
||||
let _reconnectTimer = null;
|
||||
let _ws: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
|
||||
const _RECONNECT_MIN = 1000;
|
||||
const _RECONNECT_MAX = 30000;
|
||||
@@ -7,10 +7,10 @@
|
||||
* parameterised by which filter array, filter definitions, DOM IDs, etc.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHtml } from './api.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
import * as P from './icon-paths.js';
|
||||
import { t } from './i18n.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { IconSelect } from './icon-select.ts';
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
const _FILTER_ICONS = {
|
||||
brightness: P.sunDim,
|
||||
@@ -30,22 +30,35 @@ const _FILTER_ICONS = {
|
||||
|
||||
export { _FILTER_ICONS };
|
||||
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {Function} opts.getFilters - () => filtersArr (mutable reference)
|
||||
* @param {Function} opts.getFilterDefs - () => filterDefs array
|
||||
* @param {Function} opts.getFilterName - (filterId) => display name
|
||||
* @param {string} opts.selectId - DOM id of the <select> for adding filters
|
||||
* @param {string} opts.containerId - DOM id of the filter list container
|
||||
* @param {string} opts.prefix - handler prefix for onclick attrs: '' for PP, 'cspt' for CSPT
|
||||
* @param {string} opts.editingIdInputId - DOM id of hidden input holding the editing template ID
|
||||
* @param {string} opts.selfRefFilterId - filter_id that should exclude self (e.g. 'filter_template')
|
||||
* @param {Function} [opts.autoNameFn] - optional callback after add/remove to auto-generate name
|
||||
* @param {Function} [opts.initDrag] - drag initializer fn(containerId, filtersArr, rerenderFn)
|
||||
* @param {Function} [opts.initPaletteGrids] - palette grid initializer fn(containerEl)
|
||||
*/
|
||||
export interface FilterListOpts {
|
||||
getFilters: () => any[];
|
||||
getFilterDefs: () => any[];
|
||||
getFilterName: (filterId: string) => string;
|
||||
selectId: string;
|
||||
containerId: string;
|
||||
prefix: string;
|
||||
editingIdInputId: string;
|
||||
selfRefFilterId: string;
|
||||
autoNameFn?: () => void;
|
||||
initDrag?: (containerId: string, filtersArr: any[], rerenderFn: () => void) => void;
|
||||
initPaletteGrids?: (containerEl: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export class FilterListManager {
|
||||
constructor(opts) {
|
||||
_getFilters: () => any[];
|
||||
_getFilterDefs: () => any[];
|
||||
_getFilterName: (filterId: string) => string;
|
||||
_selectId: string;
|
||||
_containerId: string;
|
||||
_prefix: string;
|
||||
_editingIdInputId: string;
|
||||
_selfRefFilterId: string;
|
||||
_autoNameFn: (() => void) | null;
|
||||
_initDrag: ((containerId: string, filtersArr: any[], rerenderFn: () => void) => void) | null;
|
||||
_initPaletteGrids: ((containerEl: HTMLElement) => void) | null;
|
||||
_iconSelect: IconSelect | null;
|
||||
|
||||
constructor(opts: FilterListOpts) {
|
||||
this._getFilters = opts.getFilters;
|
||||
this._getFilterDefs = opts.getFilterDefs;
|
||||
this._getFilterName = opts.getFilterName;
|
||||
@@ -63,12 +76,9 @@ export class FilterListManager {
|
||||
/** Get the current IconSelect instance (for external access if needed). */
|
||||
get iconSelect() { return this._iconSelect; }
|
||||
|
||||
/**
|
||||
* Populate the filter <select> and attach/update IconSelect grid.
|
||||
* @param {Function} onChangeCallback - called when user picks a filter from the icon grid
|
||||
*/
|
||||
populateSelect(onChangeCallback) {
|
||||
const select = document.getElementById(this._selectId);
|
||||
/** Populate the filter <select> and attach/update IconSelect grid. */
|
||||
populateSelect(onChangeCallback: (value: string) => void) {
|
||||
const select = document.getElementById(this._selectId) as HTMLSelectElement;
|
||||
const filterDefs = this._getFilterDefs();
|
||||
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
||||
const items = [];
|
||||
@@ -100,7 +110,7 @@ export class FilterListManager {
|
||||
* Render the filter list into the container.
|
||||
*/
|
||||
render() {
|
||||
const container = document.getElementById(this._containerId);
|
||||
const container = document.getElementById(this._containerId) as HTMLElement;
|
||||
const filtersArr = this._getFilters();
|
||||
const filterDefs = this._getFilterDefs();
|
||||
|
||||
@@ -156,7 +166,7 @@ export class FilterListManager {
|
||||
</label>
|
||||
</div>`;
|
||||
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
|
||||
const editingId = document.getElementById(this._editingIdInputId)?.value || '';
|
||||
const editingId = (document.getElementById(this._editingIdInputId) as HTMLInputElement)?.value || '';
|
||||
const filteredChoices = (fi.filter_id === this._selfRefFilterId && opt.key === 'template_id' && editingId)
|
||||
? opt.choices.filter(c => c.value !== editingId)
|
||||
: opt.choices;
|
||||
@@ -217,7 +227,7 @@ export class FilterListManager {
|
||||
* Add a filter from the select element into the filters array.
|
||||
*/
|
||||
addFromSelect() {
|
||||
const select = document.getElementById(this._selectId);
|
||||
const select = document.getElementById(this._selectId) as HTMLSelectElement;
|
||||
const filterId = select.value;
|
||||
if (!filterId) return;
|
||||
|
||||
@@ -245,7 +255,7 @@ export class FilterListManager {
|
||||
/**
|
||||
* Toggle expand/collapse of a filter card.
|
||||
*/
|
||||
toggleExpand(index) {
|
||||
toggleExpand(index: number) {
|
||||
const filtersArr = this._getFilters();
|
||||
if (filtersArr[index]) {
|
||||
filtersArr[index]._expanded = !filtersArr[index]._expanded;
|
||||
@@ -256,19 +266,15 @@ export class FilterListManager {
|
||||
/**
|
||||
* Remove a filter at the given index.
|
||||
*/
|
||||
remove(index) {
|
||||
remove(index: number) {
|
||||
const filtersArr = this._getFilters();
|
||||
filtersArr.splice(index, 1);
|
||||
this.render();
|
||||
if (this._autoNameFn) this._autoNameFn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a filter up or down by swapping with its neighbour.
|
||||
* @param {number} index - current index
|
||||
* @param {number} direction - -1 for up, +1 for down
|
||||
*/
|
||||
move(index, direction) {
|
||||
/** Move a filter up or down by swapping with its neighbour. */
|
||||
move(index: number, direction: number) {
|
||||
const filtersArr = this._getFilters();
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= filtersArr.length) return;
|
||||
@@ -282,7 +288,7 @@ export class FilterListManager {
|
||||
/**
|
||||
* Update a single option value on a filter.
|
||||
*/
|
||||
updateOption(filterIndex, optionKey, value) {
|
||||
updateOption(filterIndex: number, optionKey: string, value: any) {
|
||||
const filtersArr = this._getFilters();
|
||||
const filterDefs = this._getFilterDefs();
|
||||
if (filtersArr[filterIndex]) {
|
||||
@@ -9,13 +9,43 @@ const PAN_DEAD_ZONE = 4; // px before drag starts
|
||||
const BOUNDS_MARGIN_FACTOR = 0.5; // allow panning half a viewport past data bounds
|
||||
|
||||
export class GraphCanvas {
|
||||
/**
|
||||
* @param {SVGSVGElement} svg
|
||||
*/
|
||||
constructor(svg) {
|
||||
svg: SVGSVGElement;
|
||||
root: SVGGElement;
|
||||
blockPan: boolean;
|
||||
|
||||
private _vx: number;
|
||||
private _vy: number;
|
||||
private _zoom: number;
|
||||
private _panning: boolean;
|
||||
private _panPending: boolean;
|
||||
private _panStart: { x: number; y: number } | null;
|
||||
private _panViewStart: { x: number; y: number } | null;
|
||||
private _listeners: any[];
|
||||
private _onZoomChange: ((zoom: number) => void) | null;
|
||||
private _onViewChange: ((viewport: any) => void) | null;
|
||||
private _justPanned: boolean;
|
||||
private _bounds: { x: number; y: number; width: number; height: number } | null;
|
||||
private _zoomVelocity: number;
|
||||
private _zoomInertiaAnim: number | null;
|
||||
private _lastWheelX: number;
|
||||
private _lastWheelY: number;
|
||||
private _pointers: Map<number, { x: number; y: number }>;
|
||||
private _pinchStartDist: number;
|
||||
private _pinchStartZoom: number;
|
||||
private _pinchMidX: number;
|
||||
private _pinchMidY: number;
|
||||
private _lastTapTime: number;
|
||||
private _lastTapX: number;
|
||||
private _lastTapY: number;
|
||||
private _isTouch: boolean;
|
||||
private _zoomAnim: number | null;
|
||||
private _resizeObs: ResizeObserver | null;
|
||||
private _lastSvgRect: DOMRect;
|
||||
private _panDeadZone: number;
|
||||
|
||||
constructor(svg: SVGSVGElement) {
|
||||
this.svg = svg;
|
||||
/** @type {SVGGElement} */
|
||||
this.root = svg.querySelector('.graph-root');
|
||||
this.root = svg.querySelector('.graph-root') as SVGGElement;
|
||||
this._vx = 0;
|
||||
this._vy = 0;
|
||||
this._zoom = 1;
|
||||
@@ -46,6 +76,10 @@ export class GraphCanvas {
|
||||
this._lastTapX = 0;
|
||||
this._lastTapY = 0;
|
||||
this._isTouch = false;
|
||||
this._zoomAnim = null;
|
||||
this._resizeObs = null;
|
||||
this._panDeadZone = PAN_DEAD_ZONE;
|
||||
this._lastSvgRect = this.svg.getBoundingClientRect();
|
||||
this._bind();
|
||||
}
|
||||
|
||||
@@ -57,11 +91,11 @@ export class GraphCanvas {
|
||||
/** True briefly after a pan gesture ends — use to suppress click-after-pan. */
|
||||
get wasPanning() { return this._justPanned; }
|
||||
|
||||
set onZoomChange(fn) { this._onZoomChange = fn; }
|
||||
set onViewChange(fn) { this._onViewChange = fn; }
|
||||
set onZoomChange(fn: ((zoom: number) => void) | null) { this._onZoomChange = fn; }
|
||||
set onViewChange(fn: ((viewport: any) => void) | null) { this._onViewChange = fn; }
|
||||
|
||||
/** Set data bounds for view clamping. */
|
||||
setBounds(bounds) { this._bounds = bounds; }
|
||||
setBounds(bounds: { x: number; y: number; width: number; height: number } | null) { this._bounds = bounds; }
|
||||
|
||||
/** Get the visible viewport in graph coordinates. */
|
||||
getViewport() {
|
||||
@@ -75,7 +109,7 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
/** Convert screen (client) coordinates to graph coordinates. */
|
||||
screenToGraph(sx, sy) {
|
||||
screenToGraph(sx: number, sy: number) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
return {
|
||||
x: (sx - r.left) / this._zoom + this._vx,
|
||||
@@ -84,7 +118,7 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
/** Set view to center on a point at current zoom. */
|
||||
panTo(gx, gy, animate = true) {
|
||||
panTo(gx: number, gy: number, animate = true) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
this._vx = gx - (r.width / this._zoom) / 2;
|
||||
this._vy = gy - (r.height / this._zoom) / 2;
|
||||
@@ -92,7 +126,7 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
/** Fit all content within the viewport with padding. */
|
||||
fitAll(bounds, animate = true) {
|
||||
fitAll(bounds: { x: number; y: number; width: number; height: number } | null, animate = true) {
|
||||
if (!bounds) return;
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
const pad = 60;
|
||||
@@ -108,7 +142,7 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
/** Set zoom level centered on screen point. */
|
||||
zoomTo(level, cx, cy) {
|
||||
zoomTo(level: number, cx?: number, cy?: number) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
const mx = cx !== undefined ? cx : r.width / 2 + r.left;
|
||||
const my = cy !== undefined ? cy : r.height / 2 + r.top;
|
||||
@@ -126,7 +160,7 @@ export class GraphCanvas {
|
||||
* Interpolates the view center in graph-space so the target smoothly
|
||||
* slides to screen center while zoom changes simultaneously.
|
||||
*/
|
||||
zoomToPoint(level, gx, gy, duration = 500) {
|
||||
zoomToPoint(level: number, gx: number, gy: number, duration = 500) {
|
||||
if (this._zoomAnim) cancelAnimationFrame(this._zoomAnim);
|
||||
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
@@ -140,7 +174,7 @@ export class GraphCanvas {
|
||||
const startCy = this._vy + hh / startZoom;
|
||||
const t0 = performance.now();
|
||||
|
||||
const step = (now) => {
|
||||
const step = (now: number) => {
|
||||
const elapsed = now - t0;
|
||||
const p = Math.min(elapsed / duration, 1);
|
||||
// Ease-in-out cubic
|
||||
@@ -169,7 +203,7 @@ export class GraphCanvas {
|
||||
zoomIn() { this._buttonZoomKick(0.06); }
|
||||
zoomOut() { this._buttonZoomKick(-0.06); }
|
||||
|
||||
_buttonZoomKick(impulse) {
|
||||
_buttonZoomKick(impulse: number) {
|
||||
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
this._lastWheelX = r.left + r.width / 2;
|
||||
@@ -190,7 +224,7 @@ export class GraphCanvas {
|
||||
|
||||
// ── Private ──
|
||||
|
||||
_on(el, ev, fn, opts) {
|
||||
_on(el: any, ev: string, fn: any, opts?: any) {
|
||||
el.addEventListener(ev, fn, opts);
|
||||
this._listeners.push([el, ev, fn, opts]);
|
||||
}
|
||||
@@ -206,7 +240,7 @@ export class GraphCanvas {
|
||||
this._resizeObs.observe(this.svg);
|
||||
this._lastSvgRect = this.svg.getBoundingClientRect();
|
||||
// Prevent default touch actions on the SVG (browser pan/zoom)
|
||||
this.svg.style.touchAction = 'none';
|
||||
(this.svg as any).style.touchAction = 'none';
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
@@ -223,7 +257,7 @@ export class GraphCanvas {
|
||||
this._applyTransform(false);
|
||||
}
|
||||
|
||||
_onWheel(e) {
|
||||
_onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
||||
|
||||
@@ -273,7 +307,7 @@ export class GraphCanvas {
|
||||
return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
|
||||
}
|
||||
|
||||
_onPointerDown(e) {
|
||||
_onPointerDown(e: PointerEvent) {
|
||||
this._isTouch = e.pointerType === 'touch';
|
||||
const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE;
|
||||
|
||||
@@ -287,7 +321,7 @@ export class GraphCanvas {
|
||||
this.svg.classList.remove('panning');
|
||||
this._pinchStartDist = this._pointerDist();
|
||||
this._pinchStartZoom = this._zoom;
|
||||
const mid = this._pointerMid();
|
||||
const mid = this._pointerMid()!;
|
||||
this._pinchMidX = mid.x;
|
||||
this._pinchMidY = mid.y;
|
||||
this._panStart = { x: mid.x, y: mid.y };
|
||||
@@ -307,7 +341,8 @@ export class GraphCanvas {
|
||||
|
||||
// Left-click / single touch on SVG background → pending pan
|
||||
if (e.button === 0 && !this.blockPan) {
|
||||
const onNode = e.target.closest('.graph-node');
|
||||
const target = e.target as Element;
|
||||
const onNode = target.closest('.graph-node');
|
||||
if (!onNode) {
|
||||
this._panPending = true;
|
||||
this._panDeadZone = deadZone;
|
||||
@@ -317,7 +352,7 @@ export class GraphCanvas {
|
||||
}
|
||||
}
|
||||
|
||||
_onPointerMove(e) {
|
||||
_onPointerMove(e: PointerEvent) {
|
||||
// Update tracked pointer position
|
||||
if (this._pointers.has(e.pointerId)) {
|
||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
@@ -328,16 +363,16 @@ export class GraphCanvas {
|
||||
const dist = this._pointerDist();
|
||||
const scale = dist / this._pinchStartDist;
|
||||
const newZoom = this._pinchStartZoom * scale;
|
||||
const mid = this._pointerMid();
|
||||
const mid = this._pointerMid()!;
|
||||
|
||||
// Zoom around pinch midpoint
|
||||
this.zoomTo(newZoom, mid.x, mid.y);
|
||||
|
||||
// Pan with pinch movement
|
||||
const dx = (mid.x - this._panStart.x) / this._zoom;
|
||||
const dy = (mid.y - this._panStart.y) / this._zoom;
|
||||
this._vx = this._panViewStart.x - dx;
|
||||
this._vy = this._panViewStart.y - dy;
|
||||
const dx = (mid.x - this._panStart!.x) / this._zoom;
|
||||
const dy = (mid.y - this._panStart!.y) / this._zoom;
|
||||
this._vx = this._panViewStart!.x - dx;
|
||||
this._vy = this._panViewStart!.y - dy;
|
||||
this._applyTransform(false);
|
||||
if (this._onZoomChange) this._onZoomChange(this._zoom);
|
||||
return;
|
||||
@@ -346,8 +381,8 @@ export class GraphCanvas {
|
||||
// Check dead-zone for pending single-finger pan
|
||||
const dz = this._panDeadZone || PAN_DEAD_ZONE;
|
||||
if (this._panPending && !this._panning) {
|
||||
const dx = e.clientX - this._panStart.x;
|
||||
const dy = e.clientY - this._panStart.y;
|
||||
const dx = e.clientX - this._panStart!.x;
|
||||
const dy = e.clientY - this._panStart!.y;
|
||||
if (Math.abs(dx) > dz || Math.abs(dy) > dz) {
|
||||
this._panning = true;
|
||||
this.svg.classList.add('panning');
|
||||
@@ -356,14 +391,14 @@ export class GraphCanvas {
|
||||
}
|
||||
|
||||
if (!this._panning) return;
|
||||
const dx = (e.clientX - this._panStart.x) / this._zoom;
|
||||
const dy = (e.clientY - this._panStart.y) / this._zoom;
|
||||
this._vx = this._panViewStart.x - dx;
|
||||
this._vy = this._panViewStart.y - dy;
|
||||
const dx = (e.clientX - this._panStart!.x) / this._zoom;
|
||||
const dy = (e.clientY - this._panStart!.y) / this._zoom;
|
||||
this._vx = this._panViewStart!.x - dx;
|
||||
this._vy = this._panViewStart!.y - dy;
|
||||
this._applyTransform(false);
|
||||
}
|
||||
|
||||
_onPointerUp(e) {
|
||||
_onPointerUp(e: PointerEvent) {
|
||||
this._pointers.delete(e.pointerId);
|
||||
|
||||
// If we were pinching and one finger lifts, reset pinch state
|
||||
@@ -409,7 +444,7 @@ export class GraphCanvas {
|
||||
}
|
||||
}
|
||||
|
||||
_startPan(e) {
|
||||
_startPan(e: PointerEvent) {
|
||||
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
|
||||
this._panning = true;
|
||||
this._panPending = false;
|
||||
@@ -3,11 +3,29 @@
|
||||
* Supports creating, changing, and detaching connections via the graph editor.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.js';
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
import {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
} from './state.js';
|
||||
} from './state.ts';
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface ConnectionEntry {
|
||||
targetKind: string;
|
||||
field: string;
|
||||
sourceKind: string;
|
||||
edgeType: string;
|
||||
endpoint?: string;
|
||||
cache?: { invalidate(): void };
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
interface CompatibleInput {
|
||||
targetKind: string;
|
||||
field: string;
|
||||
edgeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection map: for each (targetKind, field) pair, defines:
|
||||
@@ -17,7 +35,7 @@ import {
|
||||
* - cache: the DataCache to invalidate after update
|
||||
* - nested: true if this field is inside a nested structure (not editable via drag)
|
||||
*/
|
||||
const CONNECTION_MAP = [
|
||||
const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
// Picture sources
|
||||
{ targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
|
||||
{ targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache },
|
||||
@@ -59,7 +77,7 @@ const CONNECTION_MAP = [
|
||||
/**
|
||||
* Check if an edge (by field name) is editable via drag-connect.
|
||||
*/
|
||||
export function isEditableEdge(field) {
|
||||
export function isEditableEdge(field: string): boolean {
|
||||
const entry = CONNECTION_MAP.find(c => c.field === field);
|
||||
return entry ? !entry.nested : false;
|
||||
}
|
||||
@@ -68,7 +86,7 @@ export function isEditableEdge(field) {
|
||||
* Find the connection mapping for a given target kind and source kind.
|
||||
* Returns the matching entry (or entries) from CONNECTION_MAP.
|
||||
*/
|
||||
export function findConnection(targetKind, sourceKind, edgeType) {
|
||||
export function findConnection(targetKind: string, sourceKind: string, edgeType?: string): ConnectionEntry[] {
|
||||
return CONNECTION_MAP.filter(c =>
|
||||
!c.nested &&
|
||||
c.targetKind === targetKind &&
|
||||
@@ -81,7 +99,7 @@ export function findConnection(targetKind, sourceKind, edgeType) {
|
||||
* Find compatible input port fields for a given source kind.
|
||||
* Returns array of { targetKind, field, edgeType }.
|
||||
*/
|
||||
export function getCompatibleInputs(sourceKind) {
|
||||
export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
|
||||
return CONNECTION_MAP
|
||||
.filter(c => !c.nested && c.sourceKind === sourceKind)
|
||||
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
|
||||
@@ -90,7 +108,7 @@ export function getCompatibleInputs(sourceKind) {
|
||||
/**
|
||||
* Find the connection entry for a specific edge (by target kind and field).
|
||||
*/
|
||||
export function getConnectionByField(targetKind, field) {
|
||||
export function getConnectionByField(targetKind: string, field: string): ConnectionEntry | undefined {
|
||||
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
|
||||
}
|
||||
|
||||
@@ -102,7 +120,7 @@ export function getConnectionByField(targetKind, field) {
|
||||
* @param {string|null} newSourceId - New source ID, or '' to detach
|
||||
* @returns {Promise<boolean>} success
|
||||
*/
|
||||
export async function updateConnection(targetId, targetKind, field, newSourceId) {
|
||||
export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise<boolean> {
|
||||
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
|
||||
if (!entry) return false;
|
||||
|
||||
@@ -126,7 +144,7 @@ export async function updateConnection(targetId, targetKind, field, newSourceId)
|
||||
/**
|
||||
* Detach a connection (set field to null via empty-string sentinel).
|
||||
*/
|
||||
export async function detachConnection(targetId, targetKind, field) {
|
||||
export async function detachConnection(targetId: string, targetKind: string, field: string): Promise<boolean> {
|
||||
return updateConnection(targetId, targetKind, field, '');
|
||||
}
|
||||
|
||||
@@ -4,9 +4,31 @@
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svgEl(tag, attrs = {}) {
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface GraphNodeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
editable?: boolean;
|
||||
points?: { x: number; y: number }[] | null;
|
||||
fromNode?: GraphNodeRect;
|
||||
toNode?: GraphNodeRect;
|
||||
fromPortY?: number;
|
||||
toPortY?: number;
|
||||
}
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -15,7 +37,7 @@ function svgEl(tag, attrs = {}) {
|
||||
* @param {SVGGElement} group
|
||||
* @param {Array} edges - [{from, to, type, points, fromNode, toNode}]
|
||||
*/
|
||||
export function renderEdges(group, edges) {
|
||||
export function renderEdges(group: SVGGElement, edges: GraphEdge[]): void {
|
||||
while (group.firstChild) group.firstChild.remove();
|
||||
|
||||
// Defs for arrowheads
|
||||
@@ -32,7 +54,7 @@ export function renderEdges(group, edges) {
|
||||
}
|
||||
}
|
||||
|
||||
function _createArrowMarker(type) {
|
||||
function _createArrowMarker(type: string): SVGElement {
|
||||
const marker = svgEl('marker', {
|
||||
id: `arrow-${type}`,
|
||||
viewBox: '0 0 10 10',
|
||||
@@ -50,7 +72,7 @@ function _createArrowMarker(type) {
|
||||
return marker;
|
||||
}
|
||||
|
||||
function _renderEdge(edge) {
|
||||
function _renderEdge(edge: GraphEdge): SVGElement {
|
||||
const { from, to, type, fromNode, toNode, field, editable } = edge;
|
||||
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
|
||||
// Always use port-aware bezier — ELK routes without port knowledge so
|
||||
@@ -78,7 +100,7 @@ function _renderEdge(edge) {
|
||||
* Convert ELK layout points to SVG path string.
|
||||
* Uses Catmull-Rom-to-Cubic conversion for smooth curves through all points.
|
||||
*/
|
||||
function _pointsToPath(points) {
|
||||
function _pointsToPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length < 2) return '';
|
||||
if (points.length === 2) {
|
||||
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
|
||||
@@ -108,7 +130,7 @@ function _pointsToPath(points) {
|
||||
/**
|
||||
* Adjust ELK-routed start/end points to match port Y positions.
|
||||
*/
|
||||
function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) {
|
||||
function _adjustEndpoints(points: { x: number; y: number }[], fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY: number | undefined, toPortY: number | undefined): { x: number; y: number }[] {
|
||||
if (points.length < 2) return points;
|
||||
const result = points.map(p => ({ ...p }));
|
||||
if (fromPortY != null) {
|
||||
@@ -126,7 +148,7 @@ function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) {
|
||||
* Fallback bezier when no ELK routing is available.
|
||||
* Uses port Y offsets when provided, otherwise centers vertically.
|
||||
*/
|
||||
function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
|
||||
function _defaultBezier(fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY: number | undefined, toPortY: number | undefined): string {
|
||||
const x1 = fromNode.x + fromNode.width;
|
||||
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
|
||||
const x2 = toNode.x;
|
||||
@@ -138,7 +160,7 @@ function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
|
||||
/**
|
||||
* Highlight edges that connect to a specific node (upstream chain).
|
||||
*/
|
||||
export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: GraphEdge[]): Set<string> {
|
||||
// Build adjacency indexes for O(E) BFS instead of O(N*E)
|
||||
const toIndex = new Map(); // toId → [edge]
|
||||
const fromIndex = new Map(); // fromId → [edge]
|
||||
@@ -150,8 +172,8 @@ export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
}
|
||||
|
||||
// Find all ancestors recursively
|
||||
const upstream = new Set();
|
||||
const stack = [nodeId];
|
||||
const upstream = new Set<string>();
|
||||
const stack: string[] = [nodeId];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const e of (toIndex.get(current) || [])) {
|
||||
@@ -164,7 +186,7 @@ export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
upstream.add(nodeId);
|
||||
|
||||
// Downstream too
|
||||
const downstream = new Set();
|
||||
const downstream = new Set<string>();
|
||||
const dStack = [nodeId];
|
||||
while (dStack.length) {
|
||||
const current = dStack.pop();
|
||||
@@ -190,7 +212,7 @@ export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
|
||||
const from = g.getAttribute('data-from');
|
||||
const to = g.getAttribute('data-to');
|
||||
g.style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
|
||||
(g as SVGElement).style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
|
||||
});
|
||||
|
||||
return chain;
|
||||
@@ -199,12 +221,12 @@ export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
/**
|
||||
* Clear all highlight/dim classes from edges.
|
||||
*/
|
||||
export function clearEdgeHighlights(edgeGroup) {
|
||||
export function clearEdgeHighlights(edgeGroup: SVGGElement): void {
|
||||
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
|
||||
path.classList.remove('highlighted', 'dimmed');
|
||||
});
|
||||
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
|
||||
g.style.opacity = '';
|
||||
(g as SVGElement).style.opacity = '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,7 +250,7 @@ export { EDGE_COLORS };
|
||||
* Update edge paths connected to a specific node (e.g. after dragging).
|
||||
* Falls back to default bezier since ELK routing points are no longer valid.
|
||||
*/
|
||||
export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
||||
export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap: Map<string, GraphNodeRect>, edges: GraphEdge[]): void {
|
||||
for (const edge of edges) {
|
||||
if (edge.from !== nodeId && edge.to !== nodeId) continue;
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
@@ -250,7 +272,7 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
||||
* @param {Array} edges
|
||||
* @param {Set<string>} runningIds - IDs of currently running nodes
|
||||
*/
|
||||
export function renderFlowDots(group, edges, runningIds) {
|
||||
export function renderFlowDots(group: SVGGElement, edges: GraphEdge[], runningIds: Set<string>): void {
|
||||
// Clear previous flow state
|
||||
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
||||
group.querySelectorAll('.graph-edge.graph-edge-active').forEach(el => el.classList.remove('graph-edge-active'));
|
||||
@@ -273,7 +295,7 @@ export function renderFlowDots(group, edges, runningIds) {
|
||||
});
|
||||
|
||||
// Collect all upstream edges that feed into running nodes (full chain)
|
||||
const activeEdges = new Set();
|
||||
const activeEdges = new Set<number>();
|
||||
const visited = new Set();
|
||||
const stack = [...runningIds];
|
||||
while (stack.length) {
|
||||
@@ -316,7 +338,7 @@ export function renderFlowDots(group, edges, runningIds) {
|
||||
/**
|
||||
* Update flow dot paths for edges connected to a node (after drag).
|
||||
*/
|
||||
export function updateFlowDotsForNode(group, nodeId, nodeMap, edges) {
|
||||
export function updateFlowDotsForNode(group: SVGGElement, nodeId: string, nodeMap: Map<string, GraphNodeRect>, edges: GraphEdge[]): void {
|
||||
// Just remove and let caller re-render if needed; or update paths
|
||||
// For simplicity, remove all flow dots — they'll be re-added on next render cycle
|
||||
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
||||
@@ -4,6 +4,58 @@
|
||||
|
||||
import ELK from 'elkjs/lib/elk.bundled.js';
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface LayoutNode {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
subtype: string;
|
||||
tags: string[];
|
||||
running?: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface LayoutEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
field: string;
|
||||
label: string;
|
||||
type: string;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
nodes: Map<string, LayoutNode>;
|
||||
edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[];
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface PortSet {
|
||||
types: string[];
|
||||
ports: Record<string, number>;
|
||||
}
|
||||
|
||||
interface EntitiesInput {
|
||||
devices?: any[];
|
||||
captureTemplates?: any[];
|
||||
ppTemplates?: any[];
|
||||
audioTemplates?: any[];
|
||||
patternTemplates?: any[];
|
||||
syncClocks?: any[];
|
||||
pictureSources?: any[];
|
||||
audioSources?: any[];
|
||||
valueSources?: any[];
|
||||
colorStripSources?: any[];
|
||||
outputTargets?: any[];
|
||||
scenePresets?: any[];
|
||||
automations?: any[];
|
||||
csptTemplates?: any[];
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 190;
|
||||
const NODE_HEIGHT = 56;
|
||||
|
||||
@@ -26,7 +78,7 @@ const ELK_OPTIONS = {
|
||||
* @param {Object} entities - { devices, captureTemplates, pictureSources, ... }
|
||||
* @returns {Promise<{nodes: Map, edges: Array, bounds: {x,y,width,height}}>}
|
||||
*/
|
||||
export async function computeLayout(entities) {
|
||||
export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> {
|
||||
const elk = new ELK();
|
||||
const { nodes: nodeList, edges: edgeList } = buildGraph(entities);
|
||||
|
||||
@@ -72,8 +124,8 @@ export async function computeLayout(entities) {
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
let points = null;
|
||||
if (layoutEdge?.sections?.[0]) {
|
||||
const sec = layoutEdge.sections[0];
|
||||
if ((layoutEdge as any)?.sections?.[0]) {
|
||||
const sec = (layoutEdge as any).sections[0];
|
||||
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
|
||||
}
|
||||
|
||||
@@ -139,7 +191,7 @@ export const ENTITY_LABELS = {
|
||||
|
||||
/* ── Edge type (for CSS class) ── */
|
||||
|
||||
function edgeType(fromKind, toKind, field) {
|
||||
function edgeType(fromKind: string, toKind: string, field: string): string {
|
||||
if (field === 'clock_id') return 'clock';
|
||||
if (fromKind === 'device') return 'device';
|
||||
if (fromKind === 'picture_source' || toKind === 'picture_source') return 'picture';
|
||||
@@ -154,18 +206,18 @@ function edgeType(fromKind, toKind, field) {
|
||||
|
||||
/* ── Graph builder ── */
|
||||
|
||||
function buildGraph(e) {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const nodeIds = new Set();
|
||||
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
|
||||
const nodes: LayoutNode[] = [];
|
||||
const edges: LayoutEdge[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
function addNode(id, kind, name, subtype, extra = {}) {
|
||||
function addNode(id: string, kind: string, name: string, subtype: string, extra: Record<string, any> = {}): void {
|
||||
if (!id || nodeIds.has(id)) return;
|
||||
nodeIds.add(id);
|
||||
nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra });
|
||||
}
|
||||
|
||||
function addEdge(from, to, field, label = '') {
|
||||
function addEdge(from: string, to: string, field: string, label: string = ''): void {
|
||||
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
|
||||
const type = edgeType(
|
||||
nodes.find(n => n.id === from)?.kind,
|
||||
@@ -355,7 +407,7 @@ const PORT_TYPE_ORDER = ['template', 'picture', 'colorstrip', 'value', 'audio',
|
||||
* Compute input/output port positions on every node from the edge list.
|
||||
* Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts).
|
||||
*/
|
||||
export function computePorts(nodeMap, edges) {
|
||||
export function computePorts(nodeMap: Map<string, LayoutNode & { inputPorts?: PortSet; outputPorts?: PortSet; height: number }>, edges: (LayoutEdge & { fromPortY?: number; toPortY?: number })[]): void {
|
||||
// Collect which port types each node needs (keyed by edge type)
|
||||
const inputTypes = new Map(); // nodeId → Set<edgeType>
|
||||
const outputTypes = new Map(); // nodeId → Set<edgeType>
|
||||
@@ -369,7 +421,7 @@ export function computePorts(nodeMap, edges) {
|
||||
}
|
||||
|
||||
// Sort port types and assign vertical positions
|
||||
function assignPorts(typeSet, height) {
|
||||
function assignPorts(typeSet: Set<string>, height: number): PortSet {
|
||||
const types = [...typeSet].sort((a, b) => {
|
||||
const ai = PORT_TYPE_ORDER.indexOf(a);
|
||||
const bi = PORT_TYPE_ORDER.indexOf(b);
|
||||
@@ -2,14 +2,62 @@
|
||||
* SVG node rendering for the graph editor.
|
||||
*/
|
||||
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js';
|
||||
import { EDGE_COLORS } from './graph-edges.js';
|
||||
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.js';
|
||||
import { getCardColor, setCardColor } from './card-colors.js';
|
||||
import * as P from './icon-paths.js';
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.ts';
|
||||
import { EDGE_COLORS } from './graph-edges.ts';
|
||||
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts';
|
||||
import { getCardColor, setCardColor } from './card-colors.ts';
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface PortInfo {
|
||||
types: string[];
|
||||
ports: Record<string, number>;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
subtype?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
running?: boolean;
|
||||
inputPorts?: PortInfo;
|
||||
outputPorts?: PortInfo;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
interface NodeCallbacks {
|
||||
onNodeClick?: (node: GraphNode, e: MouseEvent) => void;
|
||||
onNodeDblClick?: (node: GraphNode, e: MouseEvent) => void;
|
||||
onDeleteNode?: (node: GraphNode) => void;
|
||||
onEditNode?: (node: GraphNode) => void;
|
||||
onTestNode?: (node: GraphNode) => void;
|
||||
onStartStopNode?: (node: GraphNode) => void;
|
||||
onNotificationTest?: (node: GraphNode) => void;
|
||||
onCloneNode?: (node: GraphNode) => void;
|
||||
onActivatePreset?: (node: GraphNode) => void;
|
||||
}
|
||||
|
||||
interface OverlayButton {
|
||||
icon?: string;
|
||||
svgPath?: string;
|
||||
action: string;
|
||||
cls: string;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
// ── Port type → human-readable label ──
|
||||
const PORT_LABELS = {
|
||||
template: 'Template',
|
||||
@@ -60,14 +108,14 @@ const SUBTYPE_ICONS = {
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette },
|
||||
};
|
||||
|
||||
function svgEl(tag, attrs = {}) {
|
||||
function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Truncate text to fit within maxWidth (approximate). */
|
||||
function truncate(text, maxChars = 22) {
|
||||
function truncate(text: string, maxChars: number = 22): string {
|
||||
if (!text) return '';
|
||||
return text.length > maxChars ? text.slice(0, maxChars - 1) + '\u2026' : text;
|
||||
}
|
||||
@@ -78,7 +126,7 @@ function truncate(text, maxChars = 22) {
|
||||
* @param {Map} nodeMap - id → {id, kind, name, subtype, x, y, width, height, running}
|
||||
* @param {Object} callbacks - { onNodeClick, onNodeDblClick, onDeleteNode, onEditNode, onTestNode }
|
||||
*/
|
||||
export function renderNodes(group, nodeMap, callbacks = {}) {
|
||||
export function renderNodes(group: SVGGElement, nodeMap: Map<string, GraphNode>, callbacks: NodeCallbacks = {}): void {
|
||||
// Clear existing
|
||||
while (group.firstChild) group.firstChild.remove();
|
||||
|
||||
@@ -93,20 +141,20 @@ export function renderNodes(group, nodeMap, callbacks = {}) {
|
||||
*/
|
||||
|
||||
/** Return custom color for a node, or null if none set. */
|
||||
export function getNodeColor(nodeId) {
|
||||
export function getNodeColor(nodeId: string): string | null {
|
||||
return getCardColor(nodeId) || null;
|
||||
}
|
||||
|
||||
/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */
|
||||
export function getNodeDisplayColor(nodeId, kind) {
|
||||
export function getNodeDisplayColor(nodeId: string, kind: string): string {
|
||||
return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666';
|
||||
}
|
||||
|
||||
/** Open a color picker for a graph node, positioned near the click point. */
|
||||
function _openNodeColorPicker(node, e) {
|
||||
const nodeEl = e.target.closest('.graph-node') || document.querySelector(`.graph-node[data-id="${node.id}"]`);
|
||||
function _openNodeColorPicker(node: GraphNode, e: MouseEvent): void {
|
||||
const nodeEl = (e.target as SVGElement).closest('.graph-node') as SVGElement | null || document.querySelector(`.graph-node[data-id="${node.id}"]`) as SVGElement | null;
|
||||
if (!nodeEl) return;
|
||||
const svg = nodeEl.ownerSVGElement;
|
||||
const svg = (nodeEl as SVGGraphicsElement).ownerSVGElement;
|
||||
const container = svg?.closest('.graph-container');
|
||||
if (!svg || !container) return;
|
||||
|
||||
@@ -128,6 +176,7 @@ function _openNodeColorPicker(node, e) {
|
||||
cpOverlay.innerHTML = createColorPicker({
|
||||
id: pickerId,
|
||||
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
|
||||
onPick: null,
|
||||
anchor: 'left',
|
||||
showReset: true,
|
||||
resetColor: '#808080',
|
||||
@@ -136,8 +185,8 @@ function _openNodeColorPicker(node, e) {
|
||||
|
||||
// Register callback to update the bar color
|
||||
registerColorPicker(pickerId, (hex) => {
|
||||
const bar = nodeEl.querySelector('.graph-node-color-bar');
|
||||
const barCover = bar?.nextElementSibling;
|
||||
const bar = nodeEl.querySelector('.graph-node-color-bar') as SVGElement | null;
|
||||
const barCover = bar?.nextElementSibling as SVGElement | null;
|
||||
if (bar) {
|
||||
if (hex) {
|
||||
bar.setAttribute('fill', hex);
|
||||
@@ -157,7 +206,7 @@ function _openNodeColorPicker(node, e) {
|
||||
window._cpToggle(pickerId);
|
||||
}
|
||||
|
||||
function renderNode(node, callbacks) {
|
||||
function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
|
||||
const { id, kind, name, subtype, x, y, width, height, running } = node;
|
||||
let color = getNodeColor(id);
|
||||
|
||||
@@ -333,13 +382,13 @@ const TEST_KINDS = new Set([
|
||||
'color_strip_source', 'cspt',
|
||||
]);
|
||||
|
||||
function _createOverlay(node, nodeWidth, callbacks) {
|
||||
function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallbacks): SVGElement {
|
||||
const overlay = svgEl('g', { class: 'graph-node-overlay' });
|
||||
const btnSize = 24;
|
||||
const btnGap = 2;
|
||||
|
||||
// Build button list dynamically based on node kind/subtype
|
||||
const btns = [];
|
||||
const btns: OverlayButton[] = [];
|
||||
|
||||
// Start/stop button for applicable kinds
|
||||
if (START_STOP_KINDS.has(node.kind)) {
|
||||
@@ -453,7 +502,7 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
* Patch a node's running state in-place without replacing the element.
|
||||
* Updates the start/stop button icon/class, running dot, and node CSS class.
|
||||
*/
|
||||
export function patchNodeRunning(group, node) {
|
||||
export function patchNodeRunning(group: SVGGElement, node: GraphNode): void {
|
||||
const el = group.querySelector(`.graph-node[data-id="${node.id}"]`);
|
||||
if (!el) return;
|
||||
|
||||
@@ -494,7 +543,7 @@ export function patchNodeRunning(group, node) {
|
||||
/**
|
||||
* Highlight a single node (add class, scroll to).
|
||||
*/
|
||||
export function highlightNode(group, nodeId, cls = 'search-match') {
|
||||
export function highlightNode(group: SVGGElement, nodeId: string, cls: string = 'search-match'): Element | null {
|
||||
// Remove existing highlights
|
||||
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
|
||||
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
|
||||
@@ -505,7 +554,7 @@ export function highlightNode(group, nodeId, cls = 'search-match') {
|
||||
/**
|
||||
* Mark orphan nodes (no incoming or outgoing edges).
|
||||
*/
|
||||
export function markOrphans(group, nodeMap, edges) {
|
||||
export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>, edges: GraphEdge[]): void {
|
||||
const connected = new Set();
|
||||
for (const e of edges) {
|
||||
connected.add(e.from);
|
||||
@@ -521,7 +570,7 @@ export function markOrphans(group, nodeMap, edges) {
|
||||
/**
|
||||
* Update selection state on nodes.
|
||||
*/
|
||||
export function updateSelection(group, selectedIds) {
|
||||
export function updateSelection(group: SVGGElement, selectedIds: Set<string>): void {
|
||||
group.querySelectorAll('.graph-node').forEach(n => {
|
||||
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id')));
|
||||
});
|
||||
@@ -30,7 +30,7 @@ function getPluralForm(locale, count) {
|
||||
return count === 1 ? 'one' : 'other';
|
||||
}
|
||||
|
||||
export function t(key, params = {}) {
|
||||
export function t(key: string, params: Record<string, any> = {}) {
|
||||
let text;
|
||||
if ('count' in params) {
|
||||
const form = getPluralForm(currentLocale, params.count);
|
||||
@@ -44,7 +44,7 @@ export function t(key, params = {}) {
|
||||
return text;
|
||||
}
|
||||
|
||||
async function loadTranslations(locale) {
|
||||
async function loadTranslations(locale: string) {
|
||||
try {
|
||||
const response = await fetch(`/static/locales/${locale}.json`);
|
||||
if (!response.ok) {
|
||||
@@ -71,7 +71,7 @@ export async function initLocale() {
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
|
||||
export async function setLocale(locale) {
|
||||
export async function setLocale(locale: string) {
|
||||
if (!supportedLocales[locale]) {
|
||||
locale = 'en';
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export async function setLocale(locale) {
|
||||
}
|
||||
|
||||
export function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const select = document.getElementById('locale-select') as HTMLSelectElement;
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
localStorage.setItem('locale', newLocale);
|
||||
@@ -96,7 +96,7 @@ export function changeLocale() {
|
||||
}
|
||||
|
||||
function updateLocaleSelect() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const select = document.getElementById('locale-select') as HTMLSelectElement | null;
|
||||
if (select) {
|
||||
select.value = currentLocale;
|
||||
}
|
||||
@@ -109,13 +109,13 @@ export function updateAllText() {
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
const key = el.getAttribute('data-i18n-placeholder')!;
|
||||
(el as HTMLInputElement).placeholder = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
const key = el.getAttribute('data-i18n-title')!;
|
||||
(el as HTMLElement).title = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
|
||||
@@ -2,7 +2,7 @@
|
||||
* Reusable icon-grid selector (replaces a plain <select>).
|
||||
*
|
||||
* Usage:
|
||||
* import { IconSelect } from '../core/icon-select.js';
|
||||
* import { IconSelect } from '../core/icon-select.ts';
|
||||
*
|
||||
* const sel = new IconSelect({
|
||||
* target: document.getElementById('my-select'), // the <select> to enhance
|
||||
@@ -18,14 +18,14 @@
|
||||
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
|
||||
*/
|
||||
|
||||
import { desktopFocus } from './ui.js';
|
||||
import { desktopFocus } from './ui.ts';
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
|
||||
/** Close every open icon-select popup. */
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
||||
p.classList.remove('open');
|
||||
(p as HTMLElement).classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ function _ensureGlobalListener() {
|
||||
if (_globalListenerAdded) return;
|
||||
_globalListenerAdded = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest(`.${POPUP_CLASS}`) && !target.closest('.icon-select-trigger')) {
|
||||
closeAllIconSelects();
|
||||
}
|
||||
});
|
||||
@@ -44,15 +45,33 @@ function _ensureGlobalListener() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface IconSelectItem {
|
||||
value: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface IconSelectOpts {
|
||||
target: HTMLSelectElement;
|
||||
items: IconSelectItem[];
|
||||
onChange?: (value: string) => void;
|
||||
columns?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export class IconSelect {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {HTMLSelectElement} opts.target - the <select> element to enhance
|
||||
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items
|
||||
* @param {Function} [opts.onChange] - called with (value) after user picks
|
||||
* @param {number} [opts.columns=2] - grid column count
|
||||
*/
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '' }) {
|
||||
_select: HTMLSelectElement;
|
||||
_items: IconSelectItem[];
|
||||
_onChange: ((value: string) => void) | undefined;
|
||||
_columns: number;
|
||||
_placeholder: string;
|
||||
_trigger: HTMLButtonElement;
|
||||
_popup: HTMLDivElement;
|
||||
_scrollHandler: (() => void) | null = null;
|
||||
_scrollTargets: (HTMLElement | Window)[] = [];
|
||||
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '' }: IconSelectOpts) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
@@ -72,7 +91,7 @@ export class IconSelect {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||
this._select.parentNode!.insertBefore(this._trigger, this._select.nextSibling);
|
||||
|
||||
// Build popup (portaled to body to avoid overflow clipping)
|
||||
this._popup = document.createElement('div');
|
||||
@@ -84,7 +103,7 @@ export class IconSelect {
|
||||
// Bind item clicks
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this.setValue((cell as HTMLElement).dataset.value!, true);
|
||||
this._popup.classList.remove('open');
|
||||
this._removeScrollListener();
|
||||
});
|
||||
@@ -121,7 +140,8 @@ export class IconSelect {
|
||||
}
|
||||
// Update active state in grid
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.classList.toggle('active', cell.dataset.value === val);
|
||||
const el = cell as HTMLElement;
|
||||
el.classList.toggle('active', el.dataset.value === val);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,12 +195,13 @@ export class IconSelect {
|
||||
this._removeScrollListener();
|
||||
};
|
||||
// Listen on capture phase to catch scroll on any ancestor
|
||||
let el = this._trigger.parentNode;
|
||||
let el: Node | null = this._trigger.parentNode;
|
||||
this._scrollTargets = [];
|
||||
while (el && el !== document) {
|
||||
if (el.scrollHeight > el.clientHeight || el.classList?.contains('modal-content')) {
|
||||
el.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||
this._scrollTargets.push(el);
|
||||
const htmlEl = el as HTMLElement;
|
||||
if (htmlEl.scrollHeight > htmlEl.clientHeight || htmlEl.classList?.contains('modal-content')) {
|
||||
htmlEl.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||
this._scrollTargets.push(htmlEl);
|
||||
}
|
||||
el = el.parentNode;
|
||||
}
|
||||
@@ -198,7 +219,7 @@ export class IconSelect {
|
||||
}
|
||||
|
||||
/** Change the value programmatically. */
|
||||
setValue(value, fireChange = false) {
|
||||
setValue(value: string, fireChange = false) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
if (fireChange) {
|
||||
@@ -209,12 +230,12 @@ export class IconSelect {
|
||||
}
|
||||
|
||||
/** Refresh labels (e.g. after language change). Call with new items array. */
|
||||
updateItems(items) {
|
||||
updateItems(items: IconSelectItem[]) {
|
||||
this._items = items;
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this.setValue((cell as HTMLElement).dataset.value!, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
@@ -236,13 +257,8 @@ export class IconSelect {
|
||||
* Displays a centered modal with an icon grid. When the user picks a type,
|
||||
* the overlay closes and `onPick(value)` is called. Clicking the backdrop
|
||||
* or pressing Escape dismisses without picking.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.title - heading text
|
||||
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items
|
||||
* @param {Function} opts.onPick - called with the selected value
|
||||
*/
|
||||
export function showTypePicker({ title, items, onPick }) {
|
||||
export function showTypePicker({ title, items, onPick }: { title: string; items: IconSelectItem[]; onPick: (value: string) => void }) {
|
||||
const showFilter = items.length > 9;
|
||||
|
||||
// Build cells
|
||||
@@ -269,13 +285,14 @@ export function showTypePicker({ title, items, onPick }) {
|
||||
|
||||
// Filter logic
|
||||
if (showFilter) {
|
||||
const input = overlay.querySelector('.type-picker-filter');
|
||||
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
|
||||
const allCells = overlay.querySelectorAll('.icon-select-cell');
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.toLowerCase().trim();
|
||||
allCells.forEach(cell => {
|
||||
const match = !q || cell.dataset.search.includes(q);
|
||||
cell.classList.toggle('disabled', !match);
|
||||
const el = cell as HTMLElement;
|
||||
const match = !q || el.dataset.search!.includes(q);
|
||||
el.classList.toggle('disabled', !match);
|
||||
});
|
||||
});
|
||||
// Auto-focus filter after animation (skip on touch devices to avoid keyboard popup)
|
||||
@@ -288,7 +305,7 @@ export function showTypePicker({ title, items, onPick }) {
|
||||
});
|
||||
|
||||
// Escape key
|
||||
const onKey = (e) => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
@@ -298,7 +315,7 @@ export function showTypePicker({ title, items, onPick }) {
|
||||
cell.addEventListener('click', () => {
|
||||
if (cell.classList.contains('disabled')) return;
|
||||
close();
|
||||
onPick(cell.dataset.value);
|
||||
onPick((cell as HTMLElement).dataset.value!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
* Import icons from this module instead of using inline emoji literals.
|
||||
*/
|
||||
|
||||
import * as P from './icon-paths.js';
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
// ── SVG wrapper ────────────────────────────────────────────
|
||||
const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
// ── Type-resolution maps (private) ──────────────────────────
|
||||
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
|
||||
@@ -50,42 +50,42 @@ const _audioEngineTypeIcons = { wasapi: _svg(P.volume2), sounddevice: _svg(P.m
|
||||
// ── Type-resolution getters ─────────────────────────────────
|
||||
|
||||
/** Target type → icon (fallback: zap) */
|
||||
export function getTargetTypeIcon(targetType) {
|
||||
export function getTargetTypeIcon(targetType: string): string {
|
||||
return _targetTypeIcons[targetType] || _svg(P.zap);
|
||||
}
|
||||
|
||||
/** Picture source / stream type → icon (fallback: tv) */
|
||||
export function getPictureSourceIcon(streamType) {
|
||||
export function getPictureSourceIcon(streamType: string): string {
|
||||
return _pictureSourceTypeIcons[streamType] || _svg(P.tv);
|
||||
}
|
||||
|
||||
/** Color strip source type → icon (fallback: film) */
|
||||
export function getColorStripIcon(sourceType) {
|
||||
export function getColorStripIcon(sourceType: string): string {
|
||||
return _colorStripTypeIcons[sourceType] || _svg(P.film);
|
||||
}
|
||||
|
||||
/** Value source type → icon (fallback: sliders) */
|
||||
export function getValueSourceIcon(sourceType) {
|
||||
export function getValueSourceIcon(sourceType: string): string {
|
||||
return _valueSourceTypeIcons[sourceType] || _svg(P.slidersHorizontal);
|
||||
}
|
||||
|
||||
/** Audio source type → icon (fallback: music) */
|
||||
export function getAudioSourceIcon(sourceType) {
|
||||
export function getAudioSourceIcon(sourceType: string): string {
|
||||
return _audioSourceTypeIcons[sourceType] || _svg(P.music);
|
||||
}
|
||||
|
||||
/** Device type → icon (fallback: lightbulb) */
|
||||
export function getDeviceTypeIcon(deviceType) {
|
||||
export function getDeviceTypeIcon(deviceType: string): string {
|
||||
return _deviceTypeIcons[deviceType] || _svg(P.lightbulb);
|
||||
}
|
||||
|
||||
/** Capture engine type → icon (fallback: rocket) */
|
||||
export function getEngineIcon(engineType) {
|
||||
export function getEngineIcon(engineType: string): string {
|
||||
return _engineTypeIcons[engineType] || _svg(P.rocket);
|
||||
}
|
||||
|
||||
/** Audio engine type → icon (fallback: music) */
|
||||
export function getAudioEngineIcon(engineType) {
|
||||
export function getAudioEngineIcon(engineType: string): string {
|
||||
return _audioEngineTypeIcons[engineType] || _svg(P.music);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,23 @@
|
||||
* Dirty-check: class MyModal extends Modal { snapshotValues() { ... } }
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.js';
|
||||
import { t } from './i18n.ts';
|
||||
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.ts';
|
||||
|
||||
export class Modal {
|
||||
static _stack = [];
|
||||
static _stack: Modal[] = [];
|
||||
|
||||
constructor(elementId, { backdrop = true, lock = true } = {}) {
|
||||
el: HTMLElement | null;
|
||||
errorEl: HTMLElement | null;
|
||||
_lock: boolean;
|
||||
_backdrop: boolean;
|
||||
_initialValues: Record<string, any>;
|
||||
_closing: boolean;
|
||||
_previousFocus: Element | null = null;
|
||||
|
||||
constructor(elementId: string, { backdrop = true, lock = true } = {}) {
|
||||
this.el = document.getElementById(elementId);
|
||||
this.errorEl = this.el?.querySelector('.modal-error');
|
||||
this.errorEl = this.el?.querySelector('.modal-error') as HTMLElement | null;
|
||||
this._lock = lock;
|
||||
this._backdrop = backdrop;
|
||||
this._initialValues = {};
|
||||
@@ -26,24 +34,24 @@ export class Modal {
|
||||
|
||||
open() {
|
||||
this._previousFocus = document.activeElement;
|
||||
this.el.style.display = 'flex';
|
||||
this.el!.style.display = 'flex';
|
||||
if (this._lock) lockBody();
|
||||
if (this._backdrop) setupBackdropClose(this.el, () => this.close());
|
||||
trapFocus(this.el);
|
||||
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
|
||||
trapFocus(this.el!);
|
||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||
Modal._stack.push(this);
|
||||
}
|
||||
|
||||
forceClose() {
|
||||
releaseFocus(this.el);
|
||||
this.el.style.display = 'none';
|
||||
releaseFocus(this.el!);
|
||||
this.el!.style.display = 'none';
|
||||
if (this._lock) unlockBody();
|
||||
this._initialValues = {};
|
||||
this.hideError();
|
||||
this.onForceClose();
|
||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||
if (this._previousFocus && typeof this._previousFocus.focus === 'function') {
|
||||
this._previousFocus.focus({ preventScroll: true });
|
||||
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
|
||||
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
||||
this._previousFocus = null;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +82,7 @@ export class Modal {
|
||||
return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
showError(msg: string) {
|
||||
if (this.errorEl) {
|
||||
this.errorEl.textContent = msg;
|
||||
this.errorEl.style.display = 'block';
|
||||
@@ -88,7 +96,7 @@ export class Modal {
|
||||
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
|
||||
onForceClose() {}
|
||||
|
||||
$(id) {
|
||||
$(id: string) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Cross-entity navigation — navigate to a specific card on any tab/subtab.
|
||||
*/
|
||||
|
||||
import { switchTab } from '../features/tabs.js';
|
||||
import { switchTab } from '../features/tabs.ts';
|
||||
|
||||
/**
|
||||
* Navigate to a card on any tab/subtab, expanding the section and scrolling to it.
|
||||
@@ -13,7 +13,7 @@ import { switchTab } from '../features/tabs.js';
|
||||
* @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id')
|
||||
* @param {string} cardValue Value of the data attribute
|
||||
*/
|
||||
export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
|
||||
export function navigateToCard(tab: string, subTab: string | null, sectionKey: string | null, cardAttr: string, cardValue: string) {
|
||||
// Push current location to history so browser back returns here
|
||||
history.pushState(null, '', location.hash || '#');
|
||||
|
||||
@@ -34,11 +34,11 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
|
||||
|
||||
// Expand section if collapsed
|
||||
if (sectionKey) {
|
||||
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`);
|
||||
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`) as HTMLElement | null;
|
||||
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
|
||||
if (content && content.style.display === 'none') {
|
||||
content.style.display = '';
|
||||
const chevron = header?.querySelector('.cs-chevron');
|
||||
const chevron = header?.querySelector('.cs-chevron') as HTMLElement | null;
|
||||
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
||||
const map = JSON.parse(localStorage.getItem('sections_collapsed') || '{}');
|
||||
map[sectionKey] = false;
|
||||
@@ -60,17 +60,17 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
|
||||
|
||||
// Card not in DOM — trigger data load and wait for it to appear
|
||||
_triggerTabLoad(tab);
|
||||
_waitForCard(cardAttr, cardValue, 5000, scope).then(card => {
|
||||
_waitForCard(cardAttr, cardValue, 5000, scope).then((card: any) => {
|
||||
if (card) _highlightCard(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let _highlightTimer = 0;
|
||||
let _overlayTimer = 0;
|
||||
let _prevCard = null;
|
||||
let _highlightTimer: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
let _overlayTimer: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
let _prevCard: Element | null = null;
|
||||
|
||||
function _highlightCard(card) {
|
||||
function _highlightCard(card: Element) {
|
||||
// Clear previous highlight if still active
|
||||
if (_prevCard) _prevCard.classList.remove('card-highlight');
|
||||
clearTimeout(_highlightTimer);
|
||||
@@ -86,14 +86,14 @@ function _highlightCard(card) {
|
||||
}
|
||||
|
||||
/** Trigger the tab's data load function (used when card wasn't found in DOM). */
|
||||
function _triggerTabLoad(tab) {
|
||||
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();
|
||||
}
|
||||
|
||||
function _showDimOverlay(duration) {
|
||||
function _showDimOverlay(duration: number) {
|
||||
clearTimeout(_overlayTimer);
|
||||
let overlay = document.getElementById('nav-dim-overlay');
|
||||
if (!overlay) {
|
||||
@@ -106,8 +106,8 @@ function _showDimOverlay(duration) {
|
||||
_overlayTimer = setTimeout(() => overlay.classList.remove('active'), duration);
|
||||
}
|
||||
|
||||
function _waitForCard(cardAttr, cardValue, timeout, scope = document) {
|
||||
const root = scope === document ? document.body : scope;
|
||||
function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
|
||||
const root = scope === document ? document.body : scope as HTMLElement;
|
||||
return new Promise(resolve => {
|
||||
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
|
||||
if (card) { resolve(card); return; }
|
||||
@@ -3,7 +3,7 @@
|
||||
* and adding them to a textarea (one app per line).
|
||||
*
|
||||
* Usage:
|
||||
* import { attachProcessPicker } from '../core/process-picker.js';
|
||||
* import { attachProcessPicker } from '../core/process-picker.ts';
|
||||
* attachProcessPicker(containerEl, textareaEl);
|
||||
*
|
||||
* The container must already contain:
|
||||
@@ -14,11 +14,11 @@
|
||||
* </div>
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.js';
|
||||
import { t } from './i18n.js';
|
||||
import { escapeHtml } from './api.js';
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
|
||||
function renderList(picker, processes, existing) {
|
||||
function renderList(picker: any, processes: string[], existing: Set<string>): void {
|
||||
const listEl = picker.querySelector('.process-picker-list');
|
||||
if (processes.length === 0) {
|
||||
listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`;
|
||||
@@ -42,7 +42,7 @@ function renderList(picker, processes, existing) {
|
||||
});
|
||||
}
|
||||
|
||||
async function toggle(picker) {
|
||||
async function toggle(picker: any): Promise<void> {
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
@@ -59,12 +59,12 @@ async function toggle(picker) {
|
||||
if (!resp.ok) throw new Error('Failed to fetch processes');
|
||||
const data = await resp.json();
|
||||
|
||||
const existing = new Set(
|
||||
picker._textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)
|
||||
const existing = new Set<string>(
|
||||
(picker as any)._textarea.value.split('\n').map((a: string) => a.trim().toLowerCase()).filter(Boolean)
|
||||
);
|
||||
|
||||
picker._processes = data.processes;
|
||||
picker._existing = existing;
|
||||
(picker as any)._processes = data.processes;
|
||||
(picker as any)._existing = existing;
|
||||
renderList(picker, data.processes, existing);
|
||||
searchEl.focus();
|
||||
} catch (e) {
|
||||
@@ -72,7 +72,7 @@ async function toggle(picker) {
|
||||
}
|
||||
}
|
||||
|
||||
function filter(picker) {
|
||||
function filter(picker: any): void {
|
||||
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
|
||||
const filtered = (picker._processes || []).filter(p => p.includes(query));
|
||||
renderList(picker, filtered, picker._existing || new Set());
|
||||
@@ -82,12 +82,12 @@ function filter(picker) {
|
||||
* Wire up a process picker inside `containerEl` to feed into `textareaEl`.
|
||||
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search.
|
||||
*/
|
||||
export function attachProcessPicker(containerEl, textareaEl) {
|
||||
export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
|
||||
const browseBtn = containerEl.querySelector('.btn-browse-apps');
|
||||
const picker = containerEl.querySelector('.process-picker');
|
||||
if (!browseBtn || !picker) return;
|
||||
|
||||
picker._textarea = textareaEl;
|
||||
(picker as any)._textarea = textareaEl;
|
||||
browseBtn.addEventListener('click', () => toggle(picker));
|
||||
|
||||
const searchInput = picker.querySelector('.process-picker-search');
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Shared mutable state — all global variables live here.
|
||||
*
|
||||
* ES module `export let` creates live bindings: importers always see
|
||||
* the latest value. But importers cannot reassign, so every variable
|
||||
* gets a setter function.
|
||||
*/
|
||||
|
||||
import { DataCache } from './cache.js';
|
||||
|
||||
export let apiKey = null;
|
||||
export function setApiKey(v) { apiKey = v; }
|
||||
|
||||
export let refreshInterval = null;
|
||||
export function setRefreshInterval(v) { refreshInterval = v; }
|
||||
|
||||
export let kcTestAutoRefresh = null;
|
||||
export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; }
|
||||
|
||||
export let kcTestTargetId = null;
|
||||
export function setKcTestTargetId(v) { kcTestTargetId = v; }
|
||||
|
||||
export let kcTestWs = null;
|
||||
export function setKcTestWs(v) { kcTestWs = v; }
|
||||
|
||||
export let kcTestFps = 3;
|
||||
export function setKcTestFps(v) { kcTestFps = v; }
|
||||
|
||||
export let _cachedDisplays = null;
|
||||
|
||||
export let _displayPickerCallback = null;
|
||||
export function set_displayPickerCallback(v) { _displayPickerCallback = v; }
|
||||
|
||||
export let _displayPickerSelectedIndex = null;
|
||||
export function set_displayPickerSelectedIndex(v) { _displayPickerSelectedIndex = v; }
|
||||
|
||||
// Calibration
|
||||
export const calibrationTestState = {};
|
||||
|
||||
export const EDGE_TEST_COLORS = {
|
||||
top: [255, 0, 0],
|
||||
right: [0, 255, 0],
|
||||
bottom: [0, 100, 255],
|
||||
left: [255, 255, 0]
|
||||
};
|
||||
|
||||
// Track logged errors to avoid console spam
|
||||
export const loggedErrors = new Map();
|
||||
|
||||
// Device brightness cache
|
||||
export let _deviceBrightnessCache = {};
|
||||
export function set_deviceBrightnessCache(v) { _deviceBrightnessCache = v; }
|
||||
export function updateDeviceBrightness(deviceId, value) {
|
||||
_deviceBrightnessCache = { ..._deviceBrightnessCache, [deviceId]: value };
|
||||
}
|
||||
|
||||
// Discovery state
|
||||
export let _discoveryScanRunning = false;
|
||||
export function set_discoveryScanRunning(v) { _discoveryScanRunning = v; }
|
||||
|
||||
export let _discoveryCache = {};
|
||||
export function set_discoveryCache(v) { _discoveryCache = v; }
|
||||
|
||||
// Streams / templates state
|
||||
export let _cachedStreams = [];
|
||||
export let _cachedPPTemplates = [];
|
||||
export let _cachedCaptureTemplates = [];
|
||||
export let _availableFilters = [];
|
||||
|
||||
export let availableEngines = [];
|
||||
export function setAvailableEngines(v) { availableEngines = v; }
|
||||
|
||||
export let currentEditingTemplateId = null;
|
||||
export function setCurrentEditingTemplateId(v) { currentEditingTemplateId = v; }
|
||||
|
||||
export let _streamNameManuallyEdited = false;
|
||||
export function set_streamNameManuallyEdited(v) { _streamNameManuallyEdited = v; }
|
||||
|
||||
export let _streamModalPPTemplates = [];
|
||||
export function set_streamModalPPTemplates(v) { _streamModalPPTemplates = v; }
|
||||
|
||||
export let _templateNameManuallyEdited = false;
|
||||
export function set_templateNameManuallyEdited(v) { _templateNameManuallyEdited = v; }
|
||||
|
||||
// PP template state
|
||||
export let _modalFilters = [];
|
||||
export function set_modalFilters(v) { _modalFilters = v; }
|
||||
|
||||
export let _ppTemplateNameManuallyEdited = false;
|
||||
export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; }
|
||||
|
||||
// CSPT (Color Strip Processing Template) state
|
||||
export let _csptModalFilters = [];
|
||||
export function set_csptModalFilters(v) { _csptModalFilters = v; }
|
||||
|
||||
export let _csptNameManuallyEdited = false;
|
||||
export function set_csptNameManuallyEdited(v) { _csptNameManuallyEdited = v; }
|
||||
|
||||
export let _stripFilters = [];
|
||||
|
||||
export let _cachedCSPTemplates = [];
|
||||
|
||||
// Stream test state
|
||||
export let currentTestingTemplate = null;
|
||||
export function setCurrentTestingTemplate(v) { currentTestingTemplate = v; }
|
||||
|
||||
export let _currentTestStreamId = null;
|
||||
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }
|
||||
|
||||
export let _currentTestPPTemplateId = null;
|
||||
export function set_currentTestPPTemplateId(v) { _currentTestPPTemplateId = v; }
|
||||
|
||||
export let _lastValidatedImageSource = '';
|
||||
export function set_lastValidatedImageSource(v) { _lastValidatedImageSource = v; }
|
||||
|
||||
// Target editor state
|
||||
export let _targetEditorDevices = [];
|
||||
export function set_targetEditorDevices(v) { _targetEditorDevices = v; }
|
||||
|
||||
// KC editor state
|
||||
export let _kcNameManuallyEdited = false;
|
||||
export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; }
|
||||
|
||||
// KC WebSockets
|
||||
export const kcWebSockets = {};
|
||||
|
||||
// LED Preview WebSockets
|
||||
export const ledPreviewWebSockets = {};
|
||||
|
||||
// Tutorial state
|
||||
export let activeTutorial = null;
|
||||
export function setActiveTutorial(v) { activeTutorial = v; }
|
||||
|
||||
// Confirm modal
|
||||
export let confirmResolve = null;
|
||||
export function setConfirmResolve(v) { confirmResolve = v; }
|
||||
|
||||
// Loading guards
|
||||
export let _dashboardLoading = false;
|
||||
export function set_dashboardLoading(v) { _dashboardLoading = v; }
|
||||
|
||||
export let _sourcesLoading = false;
|
||||
export function set_sourcesLoading(v) { _sourcesLoading = v; }
|
||||
|
||||
export let _automationsLoading = false;
|
||||
export function set_automationsLoading(v) { _automationsLoading = v; }
|
||||
|
||||
// Dashboard poll interval (ms), persisted in localStorage
|
||||
const _POLL_KEY = 'dashboard_poll_interval';
|
||||
const _POLL_DEFAULT = 2000;
|
||||
export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY), 10) || _POLL_DEFAULT;
|
||||
export function setDashboardPollInterval(v) {
|
||||
dashboardPollInterval = v;
|
||||
localStorage.setItem(_POLL_KEY, String(v));
|
||||
}
|
||||
|
||||
// Pattern template editor state
|
||||
export let patternEditorRects = [];
|
||||
export function setPatternEditorRects(v) { patternEditorRects = v; }
|
||||
|
||||
export let patternEditorSelectedIdx = -1;
|
||||
export function setPatternEditorSelectedIdx(v) { patternEditorSelectedIdx = v; }
|
||||
|
||||
export let patternEditorBgImage = null;
|
||||
export function setPatternEditorBgImage(v) { patternEditorBgImage = v; }
|
||||
|
||||
export let patternCanvasDragMode = null;
|
||||
export function setPatternCanvasDragMode(v) { patternCanvasDragMode = v; }
|
||||
|
||||
export let patternCanvasDragStart = null;
|
||||
export function setPatternCanvasDragStart(v) { patternCanvasDragStart = v; }
|
||||
|
||||
export let patternCanvasDragOrigRect = null;
|
||||
export function setPatternCanvasDragOrigRect(v) { patternCanvasDragOrigRect = v; }
|
||||
|
||||
export let patternEditorHoveredIdx = -1;
|
||||
export function setPatternEditorHoveredIdx(v) { patternEditorHoveredIdx = v; }
|
||||
|
||||
export let patternEditorHoverHit = null;
|
||||
export function setPatternEditorHoverHit(v) { patternEditorHoverHit = v; }
|
||||
|
||||
export const PATTERN_RECT_COLORS = [
|
||||
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
|
||||
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
|
||||
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
|
||||
];
|
||||
export const PATTERN_RECT_BORDERS = [
|
||||
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||
];
|
||||
|
||||
// Audio sources
|
||||
export let _cachedAudioSources = [];
|
||||
export let _cachedAudioTemplates = [];
|
||||
|
||||
export let availableAudioEngines = [];
|
||||
export function setAvailableAudioEngines(v) { availableAudioEngines = v; }
|
||||
|
||||
export let currentEditingAudioTemplateId = null;
|
||||
export function setCurrentEditingAudioTemplateId(v) { currentEditingAudioTemplateId = v; }
|
||||
|
||||
export let _audioTemplateNameManuallyEdited = false;
|
||||
export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManuallyEdited = v; }
|
||||
|
||||
// Value sources
|
||||
export let _cachedValueSources = [];
|
||||
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks = [];
|
||||
|
||||
// Automations
|
||||
export let _automationsCache = null;
|
||||
|
||||
// ─── DataCache instances ───────────────────────────────────────────
|
||||
// Each cache syncs its data into the existing `export let` variable
|
||||
// via a subscriber, preserving backward compatibility.
|
||||
|
||||
export const displaysCache = new DataCache({
|
||||
endpoint: '/config/displays',
|
||||
extractData: json => json.displays || [],
|
||||
defaultValue: null,
|
||||
});
|
||||
displaysCache.subscribe(v => { _cachedDisplays = v; });
|
||||
|
||||
export const streamsCache = new DataCache({
|
||||
endpoint: '/picture-sources',
|
||||
extractData: json => json.streams || [],
|
||||
});
|
||||
streamsCache.subscribe(v => { _cachedStreams = v; });
|
||||
|
||||
export const ppTemplatesCache = new DataCache({
|
||||
endpoint: '/postprocessing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
ppTemplatesCache.subscribe(v => { _cachedPPTemplates = v; });
|
||||
|
||||
export const captureTemplatesCache = new DataCache({
|
||||
endpoint: '/capture-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
|
||||
|
||||
export const audioSourcesCache = new DataCache({
|
||||
endpoint: '/audio-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
audioSourcesCache.subscribe(v => { _cachedAudioSources = v; });
|
||||
|
||||
export const audioTemplatesCache = new DataCache({
|
||||
endpoint: '/audio-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
audioTemplatesCache.subscribe(v => { _cachedAudioTemplates = v; });
|
||||
|
||||
export const valueSourcesCache = new DataCache({
|
||||
endpoint: '/value-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
|
||||
|
||||
export const syncClocksCache = new DataCache({
|
||||
endpoint: '/sync-clocks',
|
||||
extractData: json => json.clocks || [],
|
||||
});
|
||||
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
|
||||
|
||||
export const filtersCache = new DataCache({
|
||||
endpoint: '/filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
filtersCache.subscribe(v => { _availableFilters = v; });
|
||||
|
||||
export const automationsCacheObj = new DataCache({
|
||||
endpoint: '/automations',
|
||||
extractData: json => json.automations || [],
|
||||
});
|
||||
automationsCacheObj.subscribe(v => { _automationsCache = v; });
|
||||
|
||||
export const colorStripSourcesCache = new DataCache({
|
||||
endpoint: '/color-strip-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
|
||||
export const csptCache = new DataCache({
|
||||
endpoint: '/color-strip-processing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
|
||||
|
||||
export const stripFiltersCache = new DataCache({
|
||||
endpoint: '/strip-filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
stripFiltersCache.subscribe(v => { _stripFilters = v; });
|
||||
|
||||
export const devicesCache = new DataCache({
|
||||
endpoint: '/devices',
|
||||
extractData: json => json.devices || [],
|
||||
});
|
||||
|
||||
export const outputTargetsCache = new DataCache({
|
||||
endpoint: '/output-targets',
|
||||
extractData: json => json.targets || [],
|
||||
});
|
||||
|
||||
export const patternTemplatesCache = new DataCache({
|
||||
endpoint: '/pattern-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
|
||||
export const scenePresetsCache = new DataCache({
|
||||
endpoint: '/scene-presets',
|
||||
extractData: json => json.presets || [],
|
||||
});
|
||||
320
server/src/wled_controller/static/js/core/state.ts
Normal file
320
server/src/wled_controller/static/js/core/state.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Shared mutable state — all global variables live here.
|
||||
*
|
||||
* ES module `export let` creates live bindings: importers always see
|
||||
* the latest value. But importers cannot reassign, so every variable
|
||||
* gets a setter function.
|
||||
*/
|
||||
|
||||
import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate,
|
||||
} from '../types.ts';
|
||||
|
||||
export let apiKey: string | null = null;
|
||||
export function setApiKey(v: string | null) { apiKey = v; }
|
||||
|
||||
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
|
||||
|
||||
export let kcTestAutoRefresh: ReturnType<typeof setInterval> | null = null;
|
||||
export function setKcTestAutoRefresh(v: ReturnType<typeof setInterval> | null) { kcTestAutoRefresh = v; }
|
||||
|
||||
export let kcTestTargetId: string | null = null;
|
||||
export function setKcTestTargetId(v: string | null) { kcTestTargetId = v; }
|
||||
|
||||
export let kcTestWs: WebSocket | null = null;
|
||||
export function setKcTestWs(v: WebSocket | null) { kcTestWs = v; }
|
||||
|
||||
export let kcTestFps = 3;
|
||||
export function setKcTestFps(v: number) { kcTestFps = v; }
|
||||
|
||||
export let _cachedDisplays: Display[] | null = null;
|
||||
|
||||
export let _displayPickerCallback: ((index: number, display?: Display | null) => void) | null = null;
|
||||
export function set_displayPickerCallback(v: ((index: number, display?: Display | null) => void) | null) { _displayPickerCallback = v; }
|
||||
|
||||
export let _displayPickerSelectedIndex: number | null = null;
|
||||
export function set_displayPickerSelectedIndex(v: number | null) { _displayPickerSelectedIndex = v; }
|
||||
|
||||
// Calibration
|
||||
export const calibrationTestState: Record<string, any> = {};
|
||||
|
||||
export const EDGE_TEST_COLORS: Record<string, number[]> = {
|
||||
top: [255, 0, 0],
|
||||
right: [0, 255, 0],
|
||||
bottom: [0, 100, 255],
|
||||
left: [255, 255, 0]
|
||||
};
|
||||
|
||||
// Track logged errors to avoid console spam
|
||||
export const loggedErrors = new Map<string, boolean>();
|
||||
|
||||
// Device brightness cache
|
||||
export let _deviceBrightnessCache: Record<string, number> = {};
|
||||
export function set_deviceBrightnessCache(v: Record<string, number>) { _deviceBrightnessCache = v; }
|
||||
export function updateDeviceBrightness(deviceId: string, value: number) {
|
||||
_deviceBrightnessCache = { ..._deviceBrightnessCache, [deviceId]: value };
|
||||
}
|
||||
|
||||
// Discovery state
|
||||
export let _discoveryScanRunning = false;
|
||||
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
|
||||
|
||||
export let _discoveryCache: Record<string, any> = {};
|
||||
export function set_discoveryCache(v: Record<string, any>) { _discoveryCache = v; }
|
||||
|
||||
// Streams / templates state
|
||||
export let _cachedStreams: PictureSource[] = [];
|
||||
export let _cachedPPTemplates: PostprocessingTemplate[] = [];
|
||||
export let _cachedCaptureTemplates: CaptureTemplate[] = [];
|
||||
export let _availableFilters: FilterDef[] = [];
|
||||
|
||||
export let availableEngines: EngineInfo[] = [];
|
||||
export function setAvailableEngines(v: EngineInfo[]) { availableEngines = v; }
|
||||
|
||||
export let currentEditingTemplateId: string | null = null;
|
||||
export function setCurrentEditingTemplateId(v: string | null) { currentEditingTemplateId = v; }
|
||||
|
||||
export let _streamNameManuallyEdited = false;
|
||||
export function set_streamNameManuallyEdited(v: boolean) { _streamNameManuallyEdited = v; }
|
||||
|
||||
export let _streamModalPPTemplates: PostprocessingTemplate[] = [];
|
||||
export function set_streamModalPPTemplates(v: PostprocessingTemplate[]) { _streamModalPPTemplates = v; }
|
||||
|
||||
export let _templateNameManuallyEdited = false;
|
||||
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
|
||||
|
||||
// PP template state
|
||||
export let _modalFilters: any[] = [];
|
||||
export function set_modalFilters(v: any[]) { _modalFilters = v; }
|
||||
|
||||
export let _ppTemplateNameManuallyEdited = false;
|
||||
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
|
||||
|
||||
// CSPT (Color Strip Processing Template) state
|
||||
export let _csptModalFilters: any[] = [];
|
||||
export function set_csptModalFilters(v: any[]) { _csptModalFilters = v; }
|
||||
|
||||
export let _csptNameManuallyEdited = false;
|
||||
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
|
||||
|
||||
export let _stripFilters: FilterDef[] = [];
|
||||
|
||||
export let _cachedCSPTemplates: ColorStripProcessingTemplate[] = [];
|
||||
|
||||
// Stream test state
|
||||
export let currentTestingTemplate: CaptureTemplate | null = null;
|
||||
export function setCurrentTestingTemplate(v: CaptureTemplate | null) { currentTestingTemplate = v; }
|
||||
|
||||
export let _currentTestStreamId: string | null = null;
|
||||
export function set_currentTestStreamId(v: string | null) { _currentTestStreamId = v; }
|
||||
|
||||
export let _currentTestPPTemplateId: string | null = null;
|
||||
export function set_currentTestPPTemplateId(v: string | null) { _currentTestPPTemplateId = v; }
|
||||
|
||||
export let _lastValidatedImageSource = '';
|
||||
export function set_lastValidatedImageSource(v: string) { _lastValidatedImageSource = v; }
|
||||
|
||||
// Target editor state
|
||||
export let _targetEditorDevices: Device[] = [];
|
||||
export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; }
|
||||
|
||||
// KC editor state
|
||||
export let _kcNameManuallyEdited = false;
|
||||
export function set_kcNameManuallyEdited(v: boolean) { _kcNameManuallyEdited = v; }
|
||||
|
||||
// KC WebSockets
|
||||
export const kcWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
// LED Preview WebSockets
|
||||
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
// Tutorial state
|
||||
export let activeTutorial: any = null;
|
||||
export function setActiveTutorial(v: any) { activeTutorial = v; }
|
||||
|
||||
// Confirm modal
|
||||
export let confirmResolve: ((value: boolean) => void) | null = null;
|
||||
export function setConfirmResolve(v: ((value: boolean) => void) | null) { confirmResolve = v; }
|
||||
|
||||
// Loading guards
|
||||
export let _dashboardLoading = false;
|
||||
export function set_dashboardLoading(v: boolean) { _dashboardLoading = v; }
|
||||
|
||||
export let _sourcesLoading = false;
|
||||
export function set_sourcesLoading(v: boolean) { _sourcesLoading = v; }
|
||||
|
||||
export let _automationsLoading = false;
|
||||
export function set_automationsLoading(v: boolean) { _automationsLoading = v; }
|
||||
|
||||
// Dashboard poll interval (ms), persisted in localStorage
|
||||
const _POLL_KEY = 'dashboard_poll_interval';
|
||||
const _POLL_DEFAULT = 2000;
|
||||
export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY) as string, 10) || _POLL_DEFAULT;
|
||||
export function setDashboardPollInterval(v: number) {
|
||||
dashboardPollInterval = v;
|
||||
localStorage.setItem(_POLL_KEY, String(v));
|
||||
}
|
||||
|
||||
// Pattern template editor state
|
||||
export let patternEditorRects: any[] = [];
|
||||
export function setPatternEditorRects(v: any[]) { patternEditorRects = v; }
|
||||
|
||||
export let patternEditorSelectedIdx = -1;
|
||||
export function setPatternEditorSelectedIdx(v: number) { patternEditorSelectedIdx = v; }
|
||||
|
||||
export let patternEditorBgImage: HTMLImageElement | null = null;
|
||||
export function setPatternEditorBgImage(v: HTMLImageElement | null) { patternEditorBgImage = v; }
|
||||
|
||||
export let patternCanvasDragMode: string | null = null;
|
||||
export function setPatternCanvasDragMode(v: string | null) { patternCanvasDragMode = v; }
|
||||
|
||||
export let patternCanvasDragStart: { x?: number; y?: number; mx?: number; my?: number } | null = null;
|
||||
export function setPatternCanvasDragStart(v: { x?: number; y?: number; mx?: number; my?: number } | null) { patternCanvasDragStart = v; }
|
||||
|
||||
export let patternCanvasDragOrigRect: any = null;
|
||||
export function setPatternCanvasDragOrigRect(v: any) { patternCanvasDragOrigRect = v; }
|
||||
|
||||
export let patternEditorHoveredIdx = -1;
|
||||
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }
|
||||
|
||||
export let patternEditorHoverHit: string | null = null;
|
||||
export function setPatternEditorHoverHit(v: string | null) { patternEditorHoverHit = v; }
|
||||
|
||||
export const PATTERN_RECT_COLORS = [
|
||||
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
|
||||
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
|
||||
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
|
||||
];
|
||||
export const PATTERN_RECT_BORDERS = [
|
||||
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||
];
|
||||
|
||||
// Audio sources
|
||||
export let _cachedAudioSources: AudioSource[] = [];
|
||||
export let _cachedAudioTemplates: AudioTemplate[] = [];
|
||||
|
||||
export let availableAudioEngines: EngineInfo[] = [];
|
||||
export function setAvailableAudioEngines(v: EngineInfo[]) { availableAudioEngines = v; }
|
||||
|
||||
export let currentEditingAudioTemplateId: string | null = null;
|
||||
export function setCurrentEditingAudioTemplateId(v: string | null) { currentEditingAudioTemplateId = v; }
|
||||
|
||||
export let _audioTemplateNameManuallyEdited = false;
|
||||
export function set_audioTemplateNameManuallyEdited(v: boolean) { _audioTemplateNameManuallyEdited = v; }
|
||||
|
||||
// Value sources
|
||||
export let _cachedValueSources: ValueSource[] = [];
|
||||
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
|
||||
// Automations
|
||||
export let _automationsCache: Automation[] | null = null;
|
||||
|
||||
// ─── DataCache instances ───────────────────────────────────────────
|
||||
// Each cache syncs its data into the existing `export let` variable
|
||||
// via a subscriber, preserving backward compatibility.
|
||||
|
||||
export const displaysCache = new DataCache<Display[] | null>({
|
||||
endpoint: '/config/displays',
|
||||
extractData: json => json.displays || [],
|
||||
defaultValue: null,
|
||||
});
|
||||
displaysCache.subscribe(v => { _cachedDisplays = v; });
|
||||
|
||||
export const streamsCache = new DataCache<PictureSource[]>({
|
||||
endpoint: '/picture-sources',
|
||||
extractData: json => json.streams || [],
|
||||
});
|
||||
streamsCache.subscribe(v => { _cachedStreams = v; });
|
||||
|
||||
export const ppTemplatesCache = new DataCache<PostprocessingTemplate[]>({
|
||||
endpoint: '/postprocessing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
ppTemplatesCache.subscribe(v => { _cachedPPTemplates = v; });
|
||||
|
||||
export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
|
||||
endpoint: '/capture-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
|
||||
|
||||
export const audioSourcesCache = new DataCache<AudioSource[]>({
|
||||
endpoint: '/audio-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
audioSourcesCache.subscribe(v => { _cachedAudioSources = v; });
|
||||
|
||||
export const audioTemplatesCache = new DataCache<AudioTemplate[]>({
|
||||
endpoint: '/audio-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
audioTemplatesCache.subscribe(v => { _cachedAudioTemplates = v; });
|
||||
|
||||
export const valueSourcesCache = new DataCache<ValueSource[]>({
|
||||
endpoint: '/value-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
|
||||
|
||||
export const syncClocksCache = new DataCache<SyncClock[]>({
|
||||
endpoint: '/sync-clocks',
|
||||
extractData: json => json.clocks || [],
|
||||
});
|
||||
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
|
||||
|
||||
export const filtersCache = new DataCache<FilterDef[]>({
|
||||
endpoint: '/filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
filtersCache.subscribe(v => { _availableFilters = v; });
|
||||
|
||||
export const automationsCacheObj = new DataCache<Automation[]>({
|
||||
endpoint: '/automations',
|
||||
extractData: json => json.automations || [],
|
||||
});
|
||||
automationsCacheObj.subscribe(v => { _automationsCache = v; });
|
||||
|
||||
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
|
||||
endpoint: '/color-strip-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
|
||||
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
|
||||
endpoint: '/color-strip-processing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
|
||||
|
||||
export const stripFiltersCache = new DataCache<FilterDef[]>({
|
||||
endpoint: '/strip-filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
stripFiltersCache.subscribe(v => { _stripFilters = v; });
|
||||
|
||||
export const devicesCache = new DataCache<Device[]>({
|
||||
endpoint: '/devices',
|
||||
extractData: json => json.devices || [],
|
||||
});
|
||||
|
||||
export const outputTargetsCache = new DataCache<OutputTarget[]>({
|
||||
endpoint: '/output-targets',
|
||||
extractData: json => json.targets || [],
|
||||
});
|
||||
|
||||
export const patternTemplatesCache = new DataCache<PatternTemplate[]>({
|
||||
endpoint: '/pattern-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
|
||||
export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
||||
endpoint: '/scene-presets',
|
||||
extractData: json => json.presets || [],
|
||||
});
|
||||
@@ -11,8 +11,8 @@ const TAB_SVGS = {
|
||||
graph: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><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>`,
|
||||
};
|
||||
|
||||
let _el = null;
|
||||
let _currentTab = null;
|
||||
let _el: HTMLDivElement | null = null;
|
||||
let _currentTab: string | null = null;
|
||||
|
||||
function _ensureEl() {
|
||||
if (_el) return _el;
|
||||
@@ -63,7 +63,7 @@ export function initTabIndicator() {
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
|
||||
|
||||
// Set initial tab from current active button
|
||||
const active = document.querySelector('.tab-btn.active');
|
||||
const active = document.querySelector('.tab-btn.active') as HTMLElement | null;
|
||||
if (active) {
|
||||
updateTabIndicator(active.dataset.tab);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* TagInput — reusable chip-based tag input with autocomplete.
|
||||
*
|
||||
* Usage:
|
||||
* import { TagInput } from '../core/tag-input.js';
|
||||
* import { TagInput } from '../core/tag-input.ts';
|
||||
*
|
||||
* const tagInput = new TagInput(document.getElementById('my-container'));
|
||||
* tagInput.setValue(['bedroom', 'gaming']);
|
||||
@@ -13,13 +13,13 @@
|
||||
* Tags are stored lowercase, trimmed, deduplicated.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.js';
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
|
||||
let _allTagsCache = null;
|
||||
let _allTagsFetchPromise = null;
|
||||
let _allTagsCache: string[] | null = null;
|
||||
let _allTagsFetchPromise: Promise<string[]> | null = null;
|
||||
|
||||
/** Fetch all tags from API (cached). Call invalidateTagsCache() after mutations. */
|
||||
export async function fetchAllTags() {
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
if (_allTagsCache) return _allTagsCache;
|
||||
if (_allTagsFetchPromise) return _allTagsFetchPromise;
|
||||
_allTagsFetchPromise = fetchWithAuth('/tags')
|
||||
@@ -27,7 +27,7 @@ export async function fetchAllTags() {
|
||||
.then(data => {
|
||||
_allTagsCache = data.tags || [];
|
||||
_allTagsFetchPromise = null;
|
||||
return _allTagsCache;
|
||||
return _allTagsCache!;
|
||||
})
|
||||
.catch(() => {
|
||||
_allTagsFetchPromise = null;
|
||||
@@ -46,24 +46,33 @@ export function invalidateTagsCache() {
|
||||
* @param {string[]} tags
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function renderTagChips(tags) {
|
||||
export function renderTagChips(tags: string[]): string {
|
||||
if (!tags || !tags.length) return '';
|
||||
return `<div class="card-tags">${tags.map(tag =>
|
||||
`<span class="card-tag">${_escapeHtml(tag)}</span>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
function _escapeHtml(str) {
|
||||
function _escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export class TagInput {
|
||||
_container: HTMLElement;
|
||||
_tags: string[];
|
||||
_placeholder: string;
|
||||
_dropdownVisible: boolean;
|
||||
_selectedIdx: number;
|
||||
_chipsEl!: HTMLElement;
|
||||
_inputEl!: HTMLInputElement;
|
||||
_dropdownEl!: HTMLElement;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container Element to render the tag input into
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.placeholder] Placeholder text for input
|
||||
*/
|
||||
constructor(container, opts = {}) {
|
||||
constructor(container: HTMLElement, opts: any = {}) {
|
||||
this._container = container;
|
||||
this._tags = [];
|
||||
this._placeholder = opts.placeholder || 'Add tag...';
|
||||
@@ -74,11 +83,11 @@ export class TagInput {
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
getValue() {
|
||||
getValue(): string[] {
|
||||
return [...this._tags];
|
||||
}
|
||||
|
||||
setValue(tags) {
|
||||
setValue(tags: string[]) {
|
||||
this._tags = (tags || []).map(t => t.toLowerCase().trim()).filter(Boolean);
|
||||
this._tags = [...new Set(this._tags)];
|
||||
this._renderChips();
|
||||
@@ -99,9 +108,9 @@ export class TagInput {
|
||||
<div class="tag-input-dropdown"></div>
|
||||
</div>
|
||||
`;
|
||||
this._chipsEl = this._container.querySelector('.tag-input-chips');
|
||||
this._inputEl = this._container.querySelector('.tag-input-field');
|
||||
this._dropdownEl = this._container.querySelector('.tag-input-dropdown');
|
||||
this._chipsEl = this._container.querySelector('.tag-input-chips') as HTMLElement;
|
||||
this._inputEl = this._container.querySelector('.tag-input-field') as HTMLInputElement;
|
||||
this._dropdownEl = this._container.querySelector('.tag-input-dropdown') as HTMLElement;
|
||||
}
|
||||
|
||||
_renderChips() {
|
||||
@@ -113,9 +122,9 @@ export class TagInput {
|
||||
_bindEvents() {
|
||||
// Chip remove buttons
|
||||
this._chipsEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.tag-chip-remove');
|
||||
const btn = (e.target as HTMLElement).closest('.tag-chip-remove') as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
const idx = parseInt((btn as HTMLElement).dataset.idx!, 10);
|
||||
this._tags.splice(idx, 1);
|
||||
this._renderChips();
|
||||
});
|
||||
@@ -128,7 +137,7 @@ export class TagInput {
|
||||
e.preventDefault();
|
||||
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
|
||||
if (items[this._selectedIdx]) {
|
||||
this._addTag(items[this._selectedIdx].dataset.tag);
|
||||
this._addTag((items[this._selectedIdx] as HTMLElement).dataset.tag!);
|
||||
}
|
||||
} else if (this._inputEl.value.trim()) {
|
||||
e.preventDefault();
|
||||
@@ -166,12 +175,12 @@ export class TagInput {
|
||||
// Dropdown click
|
||||
this._dropdownEl.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
const item = e.target.closest('.tag-dropdown-item');
|
||||
if (item) this._addTag(item.dataset.tag);
|
||||
const item = (e.target as HTMLElement).closest('.tag-dropdown-item') as HTMLElement | null;
|
||||
if (item) this._addTag(item.dataset.tag!);
|
||||
});
|
||||
}
|
||||
|
||||
_addTag(raw) {
|
||||
_addTag(raw: string) {
|
||||
const tag = raw.toLowerCase().trim().replace(/,/g, '');
|
||||
if (!tag || this._tags.includes(tag)) {
|
||||
this._inputEl.value = '';
|
||||
@@ -214,7 +223,7 @@ export class TagInput {
|
||||
this._selectedIdx = -1;
|
||||
}
|
||||
|
||||
_moveSelection(delta) {
|
||||
_moveSelection(delta: number) {
|
||||
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
|
||||
if (!items.length) return;
|
||||
items[this._selectedIdx]?.classList.remove('tag-dropdown-active');
|
||||
@@ -13,21 +13,34 @@
|
||||
* ]
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
/** Recursively sum leaf counts in a tree node. */
|
||||
function _deepCount(node) {
|
||||
function _deepCount(node: any): number {
|
||||
if (!node.children) return node.count || 0;
|
||||
return node.children.reduce((sum, c) => sum + _deepCount(c), 0);
|
||||
return node.children.reduce((sum: number, c: any) => sum + _deepCount(c), 0);
|
||||
}
|
||||
|
||||
export class TreeNav {
|
||||
containerId: string;
|
||||
onSelect: any;
|
||||
_items: any[];
|
||||
_leafMap: Map<string, any>;
|
||||
_activeLeaf: string | null;
|
||||
_extraHtml: string;
|
||||
_observerSuppressed: boolean;
|
||||
_open: boolean;
|
||||
_outsideHandler: any;
|
||||
_escHandler: any;
|
||||
_suppressTimer: any;
|
||||
_observer: IntersectionObserver | null;
|
||||
|
||||
/**
|
||||
* @param {string} containerId - ID of the nav element to render into
|
||||
* @param {object} opts
|
||||
* @param {function} opts.onSelect - callback(leafKey, leafData) when a leaf is clicked
|
||||
* @param containerId - ID of the nav element to render into
|
||||
* @param opts
|
||||
* @param opts.onSelect - callback(leafKey, leafData) when a leaf is clicked
|
||||
*/
|
||||
constructor(containerId, { onSelect }) {
|
||||
constructor(containerId: string, { onSelect }: { onSelect: any }) {
|
||||
this.containerId = containerId;
|
||||
this.onSelect = onSelect;
|
||||
this._items = [];
|
||||
@@ -38,6 +51,8 @@ export class TreeNav {
|
||||
this._open = false;
|
||||
this._outsideHandler = null;
|
||||
this._escHandler = null;
|
||||
this._suppressTimer = null;
|
||||
this._observer = null;
|
||||
}
|
||||
|
||||
/** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */
|
||||
@@ -49,10 +64,10 @@ export class TreeNav {
|
||||
|
||||
/**
|
||||
* Full re-render of the nav.
|
||||
* @param {Array} items - tree structure (groups and/or standalone leaves)
|
||||
* @param {string} activeLeafKey
|
||||
* @param items - tree structure (groups and/or standalone leaves)
|
||||
* @param activeLeafKey
|
||||
*/
|
||||
update(items, activeLeafKey) {
|
||||
update(items: any[], activeLeafKey: string) {
|
||||
this._items = items;
|
||||
this._activeLeaf = activeLeafKey;
|
||||
this._buildLeafMap();
|
||||
@@ -60,61 +75,61 @@ export class TreeNav {
|
||||
}
|
||||
|
||||
/** Update only the counts without full re-render. */
|
||||
updateCounts(countMap) {
|
||||
updateCounts(countMap: Record<string, any>) {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) return;
|
||||
for (const [key, count] of Object.entries(countMap)) {
|
||||
// Update leaves in dropdown panel
|
||||
const els = container.querySelectorAll(`[data-tree-leaf="${key}"] .tree-count`);
|
||||
els.forEach(el => { el.textContent = count; });
|
||||
els.forEach(el => { el.textContent = String(count); });
|
||||
// Update in-memory
|
||||
const leaf = this._leafMap.get(key);
|
||||
if (leaf) leaf.count = count;
|
||||
}
|
||||
// Update group counts (bottom-up: deepest first)
|
||||
const groups = [...container.querySelectorAll('[data-tree-group]')];
|
||||
const groups = Array.from(container.querySelectorAll('[data-tree-group]')) as HTMLElement[];
|
||||
groups.reverse();
|
||||
for (const groupEl of groups) {
|
||||
let total = 0;
|
||||
for (const cnt of groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-leaf] .tree-count')) {
|
||||
total += parseInt(cnt.textContent, 10) || 0;
|
||||
for (const cnt of Array.from(groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-leaf] .tree-count'))) {
|
||||
total += parseInt(cnt.textContent || '0', 10) || 0;
|
||||
}
|
||||
for (const cnt of groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-group] > .tree-dd-group-header .tree-dd-group-count')) {
|
||||
total += parseInt(cnt.textContent, 10) || 0;
|
||||
for (const cnt of Array.from(groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-group] > .tree-dd-group-header .tree-dd-group-count'))) {
|
||||
total += parseInt(cnt.textContent || '0', 10) || 0;
|
||||
}
|
||||
const gc = groupEl.querySelector(':scope > .tree-dd-group-header .tree-dd-group-count');
|
||||
if (gc) gc.textContent = total;
|
||||
if (gc) gc.textContent = String(total);
|
||||
}
|
||||
// Update trigger display if active leaf count changed
|
||||
if (countMap[this._activeLeaf] !== undefined) {
|
||||
if (this._activeLeaf && countMap[this._activeLeaf] !== undefined) {
|
||||
this._updateTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
/** Set extra HTML appended in the trigger bar (expand/collapse buttons, etc.) */
|
||||
setExtraHtml(html) {
|
||||
setExtraHtml(html: string) {
|
||||
this._extraHtml = html;
|
||||
}
|
||||
|
||||
/** Highlight a specific leaf. */
|
||||
setActive(leafKey) {
|
||||
setActive(leafKey: string) {
|
||||
this._activeLeaf = leafKey;
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) return;
|
||||
container.querySelectorAll('[data-tree-leaf]').forEach(el =>
|
||||
el.classList.toggle('active', el.dataset.treeLeaf === leafKey)
|
||||
(el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.treeLeaf === leafKey)
|
||||
);
|
||||
this._updateTrigger();
|
||||
}
|
||||
|
||||
/** Get leaf data for a key. */
|
||||
getLeaf(key) {
|
||||
getLeaf(key: string) {
|
||||
return this._leafMap.get(key);
|
||||
}
|
||||
|
||||
/** Find the first leaf key whose subTab matches. */
|
||||
getLeafForSubTab(subTab) {
|
||||
for (const [key, leaf] of this._leafMap) {
|
||||
getLeafForSubTab(subTab: string) {
|
||||
for (const [key, leaf] of Array.from(this._leafMap)) {
|
||||
if ((leaf.subTab || key) === subTab) return key;
|
||||
}
|
||||
return null;
|
||||
@@ -127,7 +142,7 @@ export class TreeNav {
|
||||
this._collectLeaves(this._items);
|
||||
}
|
||||
|
||||
_collectLeaves(items) {
|
||||
_collectLeaves(items: any[]) {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
this._collectLeaves(item.children);
|
||||
@@ -141,7 +156,7 @@ export class TreeNav {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) return;
|
||||
|
||||
const leaf = this._leafMap.get(this._activeLeaf);
|
||||
const leaf = this._leafMap.get(this._activeLeaf!);
|
||||
const triggerIcon = leaf?.icon || '';
|
||||
const triggerTitle = leaf ? t(leaf.titleKey) : '';
|
||||
const triggerCount = leaf?.count ?? 0;
|
||||
@@ -165,9 +180,9 @@ export class TreeNav {
|
||||
this._bindEvents(container);
|
||||
}
|
||||
|
||||
_renderGroup(group, depth) {
|
||||
_renderGroup(group: any, depth: number) {
|
||||
const groupCount = _deepCount(group);
|
||||
const childrenHtml = group.children.map(child =>
|
||||
const childrenHtml = group.children.map((child: any) =>
|
||||
child.children ? this._renderGroup(child, depth + 1) : this._renderLeaf(child)
|
||||
).join('');
|
||||
|
||||
@@ -184,7 +199,7 @@ export class TreeNav {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderLeaf(leaf) {
|
||||
_renderLeaf(leaf: any) {
|
||||
const isActive = leaf.key === this._activeLeaf;
|
||||
return `
|
||||
<div class="tree-dd-leaf${isActive ? ' active' : ''}" data-tree-leaf="${leaf.key}">
|
||||
@@ -197,30 +212,30 @@ export class TreeNav {
|
||||
_updateTrigger() {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) return;
|
||||
const leaf = this._leafMap.get(this._activeLeaf);
|
||||
const leaf = this._leafMap.get(this._activeLeaf!);
|
||||
if (!leaf) return;
|
||||
const icon = container.querySelector('.tree-dd-trigger-icon');
|
||||
const title = container.querySelector('.tree-dd-trigger-title');
|
||||
const count = container.querySelector('.tree-dd-trigger-count');
|
||||
if (icon) icon.innerHTML = leaf.icon || '';
|
||||
if (title) title.textContent = t(leaf.titleKey);
|
||||
if (count) count.textContent = leaf.count ?? 0;
|
||||
if (count) count.textContent = String(leaf.count ?? 0);
|
||||
}
|
||||
|
||||
_openDropdown(container) {
|
||||
_openDropdown(container: HTMLElement) {
|
||||
if (this._open) return;
|
||||
this._open = true;
|
||||
const panel = container.querySelector('[data-tree-panel]');
|
||||
const trigger = container.querySelector('[data-tree-trigger]');
|
||||
const panel = container.querySelector('[data-tree-panel]') as HTMLElement | null;
|
||||
const trigger = container.querySelector('[data-tree-trigger]') as HTMLElement | null;
|
||||
if (panel) panel.classList.add('open');
|
||||
if (trigger) trigger.classList.add('open');
|
||||
|
||||
this._outsideHandler = (e) => {
|
||||
const inTrigger = trigger && trigger.contains(e.target);
|
||||
const inPanel = panel && panel.contains(e.target);
|
||||
this._outsideHandler = (e: Event) => {
|
||||
const inTrigger = trigger && trigger.contains(e.target as Node);
|
||||
const inPanel = panel && panel.contains(e.target as Node);
|
||||
if (!inTrigger && !inPanel) this._closeDropdown(container);
|
||||
};
|
||||
this._escHandler = (e) => {
|
||||
this._escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') this._closeDropdown(container);
|
||||
};
|
||||
// Use timeout so the current pointerdown doesn't immediately trigger close
|
||||
@@ -231,11 +246,11 @@ export class TreeNav {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_closeDropdown(container) {
|
||||
_closeDropdown(container: HTMLElement) {
|
||||
if (!this._open) return;
|
||||
this._open = false;
|
||||
const panel = container.querySelector('[data-tree-panel]');
|
||||
const trigger = container.querySelector('[data-tree-trigger]');
|
||||
const panel = container.querySelector('[data-tree-panel]') as HTMLElement | null;
|
||||
const trigger = container.querySelector('[data-tree-trigger]') as HTMLElement | null;
|
||||
if (panel) panel.classList.remove('open');
|
||||
if (trigger) trigger.classList.remove('open');
|
||||
window.removeEventListener('mousedown', this._outsideHandler, true);
|
||||
@@ -243,13 +258,13 @@ export class TreeNav {
|
||||
window.removeEventListener('keydown', this._escHandler, true);
|
||||
}
|
||||
|
||||
_bindEvents(container) {
|
||||
_bindEvents(container: HTMLElement) {
|
||||
// Trigger click — toggle dropdown
|
||||
const trigger = container.querySelector('[data-tree-trigger]');
|
||||
if (trigger) {
|
||||
trigger.addEventListener('pointerdown', (e) => {
|
||||
// Don't toggle when clicking extra buttons (expand/collapse/help)
|
||||
if (e.target.closest('.tree-dd-extra')) return;
|
||||
if ((e.target as HTMLElement).closest('.tree-dd-extra')) return;
|
||||
e.preventDefault();
|
||||
if (this._open) this._closeDropdown(container);
|
||||
else this._openDropdown(container);
|
||||
@@ -259,11 +274,11 @@ export class TreeNav {
|
||||
// Leaf click — select and close
|
||||
container.querySelectorAll('[data-tree-leaf]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const key = el.dataset.treeLeaf;
|
||||
this.setActive(key);
|
||||
const key = (el as HTMLElement).dataset.treeLeaf;
|
||||
this.setActive(key!);
|
||||
this._closeDropdown(container);
|
||||
this.suppressObserver();
|
||||
if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
|
||||
if (this.onSelect) this.onSelect(key, this._leafMap.get(key!));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -271,20 +286,20 @@ export class TreeNav {
|
||||
/**
|
||||
* Start observing card-section elements so the active tree leaf
|
||||
* follows whichever section is currently visible on screen.
|
||||
* @param {string} contentId - ID of the scrollable content container
|
||||
* @param {Object<string,string>} [sectionMap] - optional { data-card-section → leafKey } override
|
||||
* @param contentId - ID of the scrollable content container
|
||||
* @param sectionMap - optional { data-card-section → leafKey } override
|
||||
*/
|
||||
observeSections(contentId, sectionMap) {
|
||||
observeSections(contentId: string, sectionMap?: Record<string, string>) {
|
||||
this.stopObserving();
|
||||
const content = document.getElementById(contentId);
|
||||
if (!content) return;
|
||||
|
||||
// Build sectionKey → leafKey mapping
|
||||
const sectionToLeaf = new Map();
|
||||
const sectionToLeaf = new Map<string, string>();
|
||||
if (sectionMap) {
|
||||
for (const [sk, lk] of Object.entries(sectionMap)) sectionToLeaf.set(sk, lk);
|
||||
} else {
|
||||
for (const [key, leaf] of this._leafMap) {
|
||||
for (const [key, leaf] of Array.from(this._leafMap)) {
|
||||
sectionToLeaf.set(leaf.sectionKey || key, key);
|
||||
}
|
||||
}
|
||||
@@ -293,7 +308,7 @@ export class TreeNav {
|
||||
.getPropertyValue('--sticky-top')) || 90;
|
||||
|
||||
// Track which sections are currently intersecting
|
||||
const visible = new Set();
|
||||
const visible = new Set<Element>();
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -302,15 +317,15 @@ export class TreeNav {
|
||||
}
|
||||
if (this._observerSuppressed) return;
|
||||
// Read fresh rects to find the topmost visible section
|
||||
let bestEl = null;
|
||||
let bestEl: Element | null = null;
|
||||
let bestTop = Infinity;
|
||||
for (const el of visible) {
|
||||
for (const el of Array.from(visible)) {
|
||||
const top = el.getBoundingClientRect().top;
|
||||
if (top < bestTop) { bestTop = top; bestEl = el; }
|
||||
}
|
||||
if (bestEl) {
|
||||
const sectionKey = bestEl.dataset.cardSection;
|
||||
const leafKey = sectionToLeaf.get(sectionKey);
|
||||
const sectionKey = (bestEl as HTMLElement).dataset.cardSection;
|
||||
const leafKey = sectionToLeaf.get(sectionKey!);
|
||||
if (leafKey && leafKey !== this._activeLeaf) {
|
||||
this._activeLeaf = leafKey;
|
||||
this.setActive(leafKey);
|
||||
@@ -322,8 +337,8 @@ export class TreeNav {
|
||||
});
|
||||
|
||||
content.querySelectorAll('[data-card-section]').forEach(section => {
|
||||
if (sectionToLeaf.has(section.dataset.cardSection)) {
|
||||
this._observer.observe(section);
|
||||
if (sectionToLeaf.has((section as HTMLElement).dataset.cardSection!)) {
|
||||
this._observer!.observe(section);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* UI utilities — modal helpers, lightbox, toast, confirm.
|
||||
*/
|
||||
|
||||
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
|
||||
export function isTouchDevice() {
|
||||
@@ -11,12 +11,12 @@ export function isTouchDevice() {
|
||||
}
|
||||
|
||||
/** Focus element only on non-touch devices (avoids virtual keyboard popup on mobile) */
|
||||
export function desktopFocus(el) {
|
||||
export function desktopFocus(el: HTMLElement | null) {
|
||||
if (el && !isTouchDevice()) el.focus();
|
||||
}
|
||||
|
||||
export function toggleHint(btn) {
|
||||
const hint = btn.closest('.label-row').nextElementSibling;
|
||||
export function toggleHint(btn: HTMLElement) {
|
||||
const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null;
|
||||
if (hint && hint.classList.contains('input-hint')) {
|
||||
const visible = hint.style.display !== 'none';
|
||||
hint.style.display = visible ? 'none' : 'block';
|
||||
@@ -27,10 +27,10 @@ export function toggleHint(btn) {
|
||||
|
||||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function trapFocus(modal) {
|
||||
modal._trapHandler = (e) => {
|
||||
export function trapFocus(modal: any) {
|
||||
modal._trapHandler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = [...modal.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null);
|
||||
const focusable = [...modal.querySelectorAll(FOCUSABLE)].filter((el: HTMLElement) => el.offsetParent !== null);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
@@ -43,14 +43,14 @@ export function trapFocus(modal) {
|
||||
modal.addEventListener('keydown', modal._trapHandler);
|
||||
}
|
||||
|
||||
export function releaseFocus(modal) {
|
||||
export function releaseFocus(modal: any) {
|
||||
if (modal._trapHandler) {
|
||||
modal.removeEventListener('keydown', modal._trapHandler);
|
||||
modal._trapHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupBackdropClose(modal, closeFn) {
|
||||
export function setupBackdropClose(modal: any, closeFn: () => void) {
|
||||
if (modal._backdropCloseSetup) {
|
||||
modal._backdropCloseFn = closeFn;
|
||||
return;
|
||||
@@ -88,10 +88,10 @@ export function unlockBody() {
|
||||
}
|
||||
}
|
||||
|
||||
export function openLightbox(imageSrc, statsHtml) {
|
||||
const lightbox = document.getElementById('image-lightbox');
|
||||
const img = document.getElementById('lightbox-image');
|
||||
const statsEl = document.getElementById('lightbox-stats');
|
||||
export function openLightbox(imageSrc: string, statsHtml?: string) {
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
const img = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
const statsEl = document.getElementById('lightbox-stats')!;
|
||||
img.src = imageSrc;
|
||||
if (statsHtml) {
|
||||
statsEl.innerHTML = statsHtml;
|
||||
@@ -103,18 +103,18 @@ export function openLightbox(imageSrc, statsHtml) {
|
||||
lockBody();
|
||||
}
|
||||
|
||||
export function closeLightbox(event) {
|
||||
if (event && event.target && event.target.closest('.lightbox-content')) return;
|
||||
export function closeLightbox(event?: Event) {
|
||||
if (event && event.target && (event.target as HTMLElement).closest('.lightbox-content')) return;
|
||||
// Stop KC test WS if running
|
||||
stopKCTestAutoRefresh();
|
||||
const lightbox = document.getElementById('image-lightbox');
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
lightbox.classList.remove('active');
|
||||
const img = document.getElementById('lightbox-image');
|
||||
const img = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||
img.src = '';
|
||||
img.style.display = '';
|
||||
document.getElementById('lightbox-stats').style.display = 'none';
|
||||
const spinner = lightbox.querySelector('.lightbox-spinner');
|
||||
document.getElementById('lightbox-stats')!.style.display = 'none';
|
||||
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
unlockBody();
|
||||
}
|
||||
@@ -131,8 +131,8 @@ export function stopKCTestAutoRefresh() {
|
||||
setKcTestTargetId(null);
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast');
|
||||
export function showToast(message: string, type = 'info') {
|
||||
const toast = document.getElementById('toast')!;
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
setTimeout(() => {
|
||||
@@ -140,15 +140,15 @@ export function showToast(message, type = 'info') {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function showConfirm(message, title = null) {
|
||||
export function showConfirm(message: string, title: string | null = null) {
|
||||
return new Promise((resolve) => {
|
||||
setConfirmResolve(resolve);
|
||||
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
const titleEl = document.getElementById('confirm-modal-title');
|
||||
const messageEl = document.getElementById('confirm-message');
|
||||
const yesBtn = document.getElementById('confirm-yes-btn');
|
||||
const noBtn = document.getElementById('confirm-no-btn');
|
||||
const modal = document.getElementById('confirm-modal')!;
|
||||
const titleEl = document.getElementById('confirm-modal-title')!;
|
||||
const messageEl = document.getElementById('confirm-message')!;
|
||||
const yesBtn = document.getElementById('confirm-yes-btn')!;
|
||||
const noBtn = document.getElementById('confirm-no-btn')!;
|
||||
|
||||
titleEl.textContent = title || t('confirm.title');
|
||||
messageEl.textContent = message;
|
||||
@@ -161,8 +161,8 @@ export function showConfirm(message, title = null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function closeConfirmModal(result) {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
export function closeConfirmModal(result: boolean) {
|
||||
const modal = document.getElementById('confirm-modal')!;
|
||||
releaseFocus(modal);
|
||||
modal.style.display = 'none';
|
||||
unlockBody();
|
||||
@@ -173,7 +173,7 @@ export function closeConfirmModal(result) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFullImageLightbox(imageSource) {
|
||||
export async function openFullImageLightbox(imageSource: string) {
|
||||
try {
|
||||
const { API_BASE, getHeaders } = await import('./api.js');
|
||||
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
|
||||
@@ -189,7 +189,7 @@ export async function openFullImageLightbox(imageSource) {
|
||||
}
|
||||
|
||||
// Overlay spinner (used by capture/stream tests)
|
||||
export function showOverlaySpinner(text, duration = 0) {
|
||||
export function showOverlaySpinner(text: string, duration = 0) {
|
||||
const existing = document.getElementById('overlay-spinner');
|
||||
if (existing) {
|
||||
if (window.overlaySpinnerTimer) {
|
||||
@@ -215,7 +215,7 @@ export function showOverlaySpinner(text, duration = 0) {
|
||||
overlay.appendChild(closeBtn);
|
||||
|
||||
// ESC key handler
|
||||
function onEsc(e) {
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') hideOverlaySpinner();
|
||||
}
|
||||
window._overlayEscHandler = onEsc;
|
||||
@@ -236,15 +236,15 @@ export function showOverlaySpinner(text, duration = 0) {
|
||||
bgCircle.setAttribute('class', 'progress-ring-bg');
|
||||
bgCircle.setAttribute('cx', '60');
|
||||
bgCircle.setAttribute('cy', '60');
|
||||
bgCircle.setAttribute('r', radius);
|
||||
bgCircle.setAttribute('r', String(radius));
|
||||
|
||||
const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
progressCircle.setAttribute('class', 'progress-ring-circle');
|
||||
progressCircle.setAttribute('cx', '60');
|
||||
progressCircle.setAttribute('cy', '60');
|
||||
progressCircle.setAttribute('r', radius);
|
||||
progressCircle.style.strokeDasharray = circumference;
|
||||
progressCircle.style.strokeDashoffset = circumference;
|
||||
progressCircle.setAttribute('r', String(radius));
|
||||
progressCircle.style.strokeDasharray = String(circumference);
|
||||
progressCircle.style.strokeDashoffset = String(circumference);
|
||||
|
||||
svg.appendChild(bgCircle);
|
||||
svg.appendChild(progressCircle);
|
||||
@@ -288,7 +288,7 @@ export function showOverlaySpinner(text, duration = 0) {
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const percentage = Math.round(progress * 100);
|
||||
const offset = circumference - (progress * circumference);
|
||||
progressCircle.style.strokeDashoffset = offset;
|
||||
progressCircle.style.strokeDashoffset = String(offset);
|
||||
progressPercentage.textContent = `${percentage}%`;
|
||||
if (progress >= 1) {
|
||||
clearInterval(window.overlaySpinnerTimer);
|
||||
@@ -319,8 +319,8 @@ export function hideOverlaySpinner() {
|
||||
* Update the overlay spinner with a live preview thumbnail and stats.
|
||||
* Call this while the spinner is open to show intermediate test frames.
|
||||
*/
|
||||
export function updateOverlayPreview(thumbnailSrc, stats) {
|
||||
const img = document.getElementById('overlay-preview-img');
|
||||
export function updateOverlayPreview(thumbnailSrc: string, stats: any) {
|
||||
const img = document.getElementById('overlay-preview-img') as HTMLImageElement | null;
|
||||
const statsEl = document.getElementById('overlay-preview-stats');
|
||||
if (!img || !statsEl) return;
|
||||
if (thumbnailSrc) {
|
||||
@@ -335,8 +335,8 @@ export function updateOverlayPreview(thumbnailSrc, stats) {
|
||||
|
||||
/** Toggle the thin loading bar on a tab panel during data refresh.
|
||||
* Delays showing the bar by 400ms so quick loads never flash it. */
|
||||
const _refreshTimers = {};
|
||||
export function setTabRefreshing(containerId, refreshing) {
|
||||
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
export function setTabRefreshing(containerId: string, refreshing: boolean) {
|
||||
const panel = document.getElementById(containerId)?.closest('.tab-panel');
|
||||
if (!panel) return;
|
||||
if (refreshing) {
|
||||
@@ -351,7 +351,7 @@ export function setTabRefreshing(containerId, refreshing) {
|
||||
}
|
||||
|
||||
/** Format a large number compactly: 999 → "999", 1200 → "1.2K", 2500000 → "2.5M" */
|
||||
export function formatCompact(n) {
|
||||
export function formatCompact(n: number | null | undefined) {
|
||||
if (n == null || n < 0) return '-';
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) {
|
||||
@@ -366,7 +366,7 @@ export function formatCompact(n) {
|
||||
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
|
||||
}
|
||||
|
||||
export function formatUptime(seconds) {
|
||||
export function formatUptime(seconds: number | null | undefined) {
|
||||
if (!seconds || seconds <= 0) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@@ -5,13 +5,69 @@
|
||||
* The canvas shows monitor rectangles that can be repositioned for visual clarity.
|
||||
*/
|
||||
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { getPictureSourceIcon } from '../core/icons.js';
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.ts';
|
||||
import { colorStripSourcesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { getPictureSourceIcon } from '../core/icons.ts';
|
||||
import type { Calibration, CalibrationLine, PictureSource } from '../types.ts';
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────── */
|
||||
|
||||
interface MonitorRect {
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
cw: number;
|
||||
ch: number;
|
||||
}
|
||||
|
||||
interface CalibrationState {
|
||||
cssId: string | null;
|
||||
lines: CalibrationLine[];
|
||||
monitors: MonitorRect[];
|
||||
pictureSources: PictureSource[];
|
||||
totalLedCount: number;
|
||||
selectedLine: number;
|
||||
dragging: DragState | null;
|
||||
}
|
||||
|
||||
interface DragMonitorState {
|
||||
type: 'monitor';
|
||||
monIdx: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origCx: number;
|
||||
origCy: number;
|
||||
}
|
||||
|
||||
interface DragPanState {
|
||||
type: 'pan';
|
||||
startX: number;
|
||||
startY: number;
|
||||
origPanX: number;
|
||||
origPanY: number;
|
||||
}
|
||||
|
||||
type DragState = DragMonitorState | DragPanState;
|
||||
|
||||
interface ViewState {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
interface LineCoords {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/* ── Constants ──────────────────────────────────────────────── */
|
||||
|
||||
@@ -36,18 +92,18 @@ const LINE_THICKNESS_PX = 6;
|
||||
|
||||
/* ── State ──────────────────────────────────────────────────── */
|
||||
|
||||
let _state = {
|
||||
let _state: CalibrationState = {
|
||||
cssId: null,
|
||||
lines: [], // [{picture_source_id, edge, led_count, span_start, span_end, reverse, border_width}]
|
||||
monitors: [], // [{id, name, width, height, cx, cy, cw, ch}] — canvas coords
|
||||
pictureSources: [], // raw API data
|
||||
totalLedCount: 0, // total LED count from the CSS source
|
||||
lines: [],
|
||||
monitors: [],
|
||||
pictureSources: [],
|
||||
totalLedCount: 0,
|
||||
selectedLine: -1,
|
||||
dragging: null, // {type:'monitor'|'pan', ...}
|
||||
dragging: null,
|
||||
};
|
||||
|
||||
// Zoom/pan view state
|
||||
let _view = { panX: 0, panY: 0, zoom: 1.0 };
|
||||
let _view: ViewState = { panX: 0, panY: 0, zoom: 1.0 };
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 4.0;
|
||||
|
||||
@@ -55,15 +111,15 @@ const MAX_ZOOM = 4.0;
|
||||
|
||||
class AdvancedCalibrationModal extends Modal {
|
||||
constructor() { super('advanced-calibration-modal'); }
|
||||
snapshotValues() {
|
||||
snapshotValues(): Record<string, string> {
|
||||
return {
|
||||
lines: JSON.stringify(_state.lines),
|
||||
offset: document.getElementById('advcal-offset')?.value || '0',
|
||||
skipStart: document.getElementById('advcal-skip-start')?.value || '0',
|
||||
skipEnd: document.getElementById('advcal-skip-end')?.value || '0',
|
||||
offset: (document.getElementById('advcal-offset') as HTMLInputElement)?.value || '0',
|
||||
skipStart: (document.getElementById('advcal-skip-start') as HTMLInputElement)?.value || '0',
|
||||
skipEnd: (document.getElementById('advcal-skip-end') as HTMLInputElement)?.value || '0',
|
||||
};
|
||||
}
|
||||
onForceClose() {
|
||||
onForceClose(): void {
|
||||
if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
|
||||
_state.cssId = null;
|
||||
_state.lines = [];
|
||||
@@ -76,7 +132,7 @@ const _modal = new AdvancedCalibrationModal();
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────── */
|
||||
|
||||
export async function showAdvancedCalibration(cssId) {
|
||||
export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
||||
try {
|
||||
const [cssSources, psResp] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
@@ -84,14 +140,14 @@ export async function showAdvancedCalibration(cssId) {
|
||||
]);
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {};
|
||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
|
||||
_state.cssId = cssId;
|
||||
_state.pictureSources = psList;
|
||||
_state.totalLedCount = source.led_count || 0;
|
||||
|
||||
document.getElementById('advcal-css-id').value = cssId;
|
||||
(document.getElementById('advcal-css-id') as HTMLInputElement).value = cssId;
|
||||
|
||||
// Populate picture source selector
|
||||
_populateSourceSelect(psList);
|
||||
@@ -103,9 +159,9 @@ export async function showAdvancedCalibration(cssId) {
|
||||
_state.lines = [];
|
||||
}
|
||||
|
||||
document.getElementById('advcal-offset').value = calibration.offset || 0;
|
||||
document.getElementById('advcal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('advcal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
(document.getElementById('advcal-offset') as HTMLInputElement).value = String(calibration.offset || 0);
|
||||
(document.getElementById('advcal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0);
|
||||
(document.getElementById('advcal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0);
|
||||
|
||||
// Build monitor rectangles from used picture sources and fit view
|
||||
_rebuildUsedMonitors();
|
||||
@@ -131,11 +187,11 @@ export async function showAdvancedCalibration(cssId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAdvancedCalibration() {
|
||||
export async function closeAdvancedCalibration(): Promise<void> {
|
||||
await _modal.close();
|
||||
}
|
||||
|
||||
export async function saveAdvancedCalibration() {
|
||||
export async function saveAdvancedCalibration(): Promise<void> {
|
||||
const cssId = _state.cssId;
|
||||
if (!cssId) return;
|
||||
|
||||
@@ -155,9 +211,9 @@ export async function saveAdvancedCalibration() {
|
||||
reverse: l.reverse,
|
||||
border_width: l.border_width,
|
||||
})),
|
||||
offset: parseInt(document.getElementById('advcal-offset').value) || 0,
|
||||
skip_leds_start: parseInt(document.getElementById('advcal-skip-start').value) || 0,
|
||||
skip_leds_end: parseInt(document.getElementById('advcal-skip-end').value) || 0,
|
||||
offset: parseInt((document.getElementById('advcal-offset') as HTMLInputElement).value) || 0,
|
||||
skip_leds_start: parseInt((document.getElementById('advcal-skip-start') as HTMLInputElement).value) || 0,
|
||||
skip_leds_end: parseInt((document.getElementById('advcal-skip-end') as HTMLInputElement).value) || 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -182,7 +238,7 @@ export async function saveAdvancedCalibration() {
|
||||
}
|
||||
}
|
||||
|
||||
export function addCalibrationLine() {
|
||||
export function addCalibrationLine(): void {
|
||||
const defaultSource = _state.pictureSources[0]?.id || '';
|
||||
const hadMonitors = _state.monitors.length;
|
||||
_state.lines.push({
|
||||
@@ -207,7 +263,7 @@ export function addCalibrationLine() {
|
||||
_updateTotalLeds();
|
||||
}
|
||||
|
||||
export function removeCalibrationLine(idx) {
|
||||
export function removeCalibrationLine(idx: number): void {
|
||||
if (idx < 0 || idx >= _state.lines.length) return;
|
||||
_state.lines.splice(idx, 1);
|
||||
if (_state.selectedLine >= _state.lines.length) {
|
||||
@@ -220,7 +276,7 @@ export function removeCalibrationLine(idx) {
|
||||
_updateTotalLeds();
|
||||
}
|
||||
|
||||
export function selectCalibrationLine(idx) {
|
||||
export function selectCalibrationLine(idx: number): void {
|
||||
const prev = _state.selectedLine;
|
||||
_state.selectedLine = idx;
|
||||
// Update selection in-place without rebuilding the list DOM
|
||||
@@ -232,7 +288,7 @@ export function selectCalibrationLine(idx) {
|
||||
_renderCanvas();
|
||||
}
|
||||
|
||||
export function moveCalibrationLine(idx, direction) {
|
||||
export function moveCalibrationLine(idx: number, direction: number): void {
|
||||
const newIdx = idx + direction;
|
||||
if (newIdx < 0 || newIdx >= _state.lines.length) return;
|
||||
[_state.lines[idx], _state.lines[newIdx]] = [_state.lines[newIdx], _state.lines[idx]];
|
||||
@@ -242,18 +298,18 @@ export function moveCalibrationLine(idx, direction) {
|
||||
_renderCanvas();
|
||||
}
|
||||
|
||||
export function updateCalibrationLine() {
|
||||
export function updateCalibrationLine(): void {
|
||||
const idx = _state.selectedLine;
|
||||
if (idx < 0 || idx >= _state.lines.length) return;
|
||||
const line = _state.lines[idx];
|
||||
const hadMonitors = _state.monitors.length;
|
||||
line.picture_source_id = document.getElementById('advcal-line-source').value;
|
||||
line.edge = document.getElementById('advcal-line-edge').value;
|
||||
line.led_count = Math.max(1, parseInt(document.getElementById('advcal-line-leds').value) || 1);
|
||||
line.span_start = parseFloat(document.getElementById('advcal-line-span-start').value) || 0;
|
||||
line.span_end = parseFloat(document.getElementById('advcal-line-span-end').value) || 1;
|
||||
line.border_width = Math.max(1, parseInt(document.getElementById('advcal-line-border-width').value) || 10);
|
||||
line.reverse = document.getElementById('advcal-line-reverse').checked;
|
||||
line.picture_source_id = (document.getElementById('advcal-line-source') as HTMLSelectElement).value;
|
||||
line.edge = (document.getElementById('advcal-line-edge') as HTMLSelectElement).value as CalibrationLine['edge'];
|
||||
line.led_count = Math.max(1, parseInt((document.getElementById('advcal-line-leds') as HTMLInputElement).value) || 1);
|
||||
line.span_start = parseFloat((document.getElementById('advcal-line-span-start') as HTMLInputElement).value) || 0;
|
||||
line.span_end = parseFloat((document.getElementById('advcal-line-span-end') as HTMLInputElement).value) || 1;
|
||||
line.border_width = Math.max(1, parseInt((document.getElementById('advcal-line-border-width') as HTMLInputElement).value) || 10);
|
||||
line.reverse = (document.getElementById('advcal-line-reverse') as HTMLInputElement).checked;
|
||||
_rebuildUsedMonitors();
|
||||
if (_state.monitors.length > hadMonitors) {
|
||||
_placeNewMonitor();
|
||||
@@ -266,10 +322,10 @@ export function updateCalibrationLine() {
|
||||
|
||||
/* ── Internals ──────────────────────────────────────────────── */
|
||||
|
||||
let _lineSourceEntitySelect = null;
|
||||
let _lineSourceEntitySelect: EntitySelect | null = null;
|
||||
|
||||
function _populateSourceSelect(psList) {
|
||||
const sel = document.getElementById('advcal-line-source');
|
||||
function _populateSourceSelect(psList: any[]) {
|
||||
const sel = document.getElementById('advcal-line-source') as HTMLSelectElement;
|
||||
sel.innerHTML = '';
|
||||
psList.forEach(ps => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -292,7 +348,7 @@ function _populateSourceSelect(psList) {
|
||||
});
|
||||
}
|
||||
|
||||
function _rebuildUsedMonitors() {
|
||||
function _rebuildUsedMonitors(): void {
|
||||
const usedIds = new Set(_state.lines.map(l => l.picture_source_id));
|
||||
const currentIds = new Set(_state.monitors.map(m => m.id));
|
||||
// Only rebuild if the set of used sources changed
|
||||
@@ -301,13 +357,13 @@ function _rebuildUsedMonitors() {
|
||||
_buildMonitorLayout(usedSources, _state.cssId);
|
||||
}
|
||||
|
||||
function _buildMonitorLayout(psList, cssId) {
|
||||
function _buildMonitorLayout(psList: any[], cssId: string | null): void {
|
||||
// Load saved positions from localStorage
|
||||
const savedKey = `advcal_positions_${cssId}`;
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
|
||||
|
||||
const canvas = document.getElementById('advcal-canvas');
|
||||
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
|
||||
const canvasW = canvas.width;
|
||||
const canvasH = canvas.height;
|
||||
|
||||
@@ -342,7 +398,7 @@ function _buildMonitorLayout(psList, cssId) {
|
||||
_state.monitors = monitors;
|
||||
}
|
||||
|
||||
function _saveMonitorPositions() {
|
||||
function _saveMonitorPositions(): void {
|
||||
if (!_state.cssId) return;
|
||||
const savedKey = `advcal_positions_${_state.cssId}`;
|
||||
const positions = {};
|
||||
@@ -351,7 +407,7 @@ function _saveMonitorPositions() {
|
||||
}
|
||||
|
||||
/** Place the last monitor next to the rightmost existing one. */
|
||||
function _placeNewMonitor() {
|
||||
function _placeNewMonitor(): void {
|
||||
if (_state.monitors.length < 2) return;
|
||||
const newMon = _state.monitors[_state.monitors.length - 1];
|
||||
const others = _state.monitors.slice(0, -1);
|
||||
@@ -365,21 +421,21 @@ function _placeNewMonitor() {
|
||||
_saveMonitorPositions();
|
||||
}
|
||||
|
||||
function _updateTotalLeds() {
|
||||
function _updateTotalLeds(): void {
|
||||
const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
|
||||
const el = document.getElementById('advcal-total-leds');
|
||||
if (_state.totalLedCount > 0) {
|
||||
el.textContent = `${used}/${_state.totalLedCount}`;
|
||||
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
|
||||
} else {
|
||||
el.textContent = used;
|
||||
el.textContent = String(used);
|
||||
el.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Line list rendering ────────────────────────────────────── */
|
||||
|
||||
function _renderLineList() {
|
||||
function _renderLineList(): void {
|
||||
const container = document.getElementById('advcal-line-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
@@ -413,7 +469,7 @@ function _renderLineList() {
|
||||
container.appendChild(addDiv);
|
||||
}
|
||||
|
||||
function _showLineProps() {
|
||||
function _showLineProps(): void {
|
||||
const propsEl = document.getElementById('advcal-line-props');
|
||||
const idx = _state.selectedLine;
|
||||
if (idx < 0 || idx >= _state.lines.length) {
|
||||
@@ -422,20 +478,20 @@ function _showLineProps() {
|
||||
}
|
||||
propsEl.style.display = '';
|
||||
const line = _state.lines[idx];
|
||||
document.getElementById('advcal-line-source').value = line.picture_source_id;
|
||||
(document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
|
||||
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
|
||||
document.getElementById('advcal-line-edge').value = line.edge;
|
||||
document.getElementById('advcal-line-leds').value = line.led_count;
|
||||
document.getElementById('advcal-line-span-start').value = line.span_start;
|
||||
document.getElementById('advcal-line-span-end').value = line.span_end;
|
||||
document.getElementById('advcal-line-border-width').value = line.border_width;
|
||||
document.getElementById('advcal-line-reverse').checked = line.reverse;
|
||||
(document.getElementById('advcal-line-edge') as HTMLSelectElement).value = line.edge;
|
||||
(document.getElementById('advcal-line-leds') as HTMLInputElement).value = String(line.led_count);
|
||||
(document.getElementById('advcal-line-span-start') as HTMLInputElement).value = String(line.span_start);
|
||||
(document.getElementById('advcal-line-span-end') as HTMLInputElement).value = String(line.span_end);
|
||||
(document.getElementById('advcal-line-border-width') as HTMLInputElement).value = String(line.border_width);
|
||||
(document.getElementById('advcal-line-reverse') as HTMLInputElement).checked = line.reverse;
|
||||
}
|
||||
|
||||
/* ── Coordinate helpers ─────────────────────────────────────── */
|
||||
|
||||
/** Convert a mouse event to world-space (pre-transform) canvas coordinates. */
|
||||
function _mouseToWorld(e, canvas) {
|
||||
function _mouseToWorld(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const sy = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||
@@ -446,7 +502,7 @@ function _mouseToWorld(e, canvas) {
|
||||
}
|
||||
|
||||
/** Convert a mouse event to raw screen-space canvas coordinates (for pan). */
|
||||
function _mouseToScreen(e, canvas) {
|
||||
function _mouseToScreen(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) * (canvas.width / rect.width),
|
||||
@@ -454,13 +510,13 @@ function _mouseToScreen(e, canvas) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resetCalibrationView() {
|
||||
export function resetCalibrationView(): void {
|
||||
_fitView();
|
||||
_renderCanvas();
|
||||
}
|
||||
|
||||
function _fitView() {
|
||||
const canvas = document.getElementById('advcal-canvas');
|
||||
function _fitView(): void {
|
||||
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas || _state.monitors.length === 0) {
|
||||
_view = { panX: 0, panY: 0, zoom: 1.0 };
|
||||
return;
|
||||
@@ -494,8 +550,8 @@ function _fitView() {
|
||||
|
||||
/* ── Canvas rendering ───────────────────────────────────────── */
|
||||
|
||||
function _renderCanvas() {
|
||||
const canvas = document.getElementById('advcal-canvas');
|
||||
function _renderCanvas(): void {
|
||||
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
@@ -559,7 +615,7 @@ function _renderCanvas() {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function _getLineCoords(mon, line) {
|
||||
function _getLineCoords(mon: MonitorRect, line: CalibrationLine): LineCoords {
|
||||
const s = line.span_start;
|
||||
const e = line.span_end;
|
||||
let x1, y1, x2, y2;
|
||||
@@ -589,7 +645,7 @@ function _getLineCoords(mon, line) {
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
function _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected) {
|
||||
function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1: number, y1: number, x2: number, y2: number, ledStart: number, mon: MonitorRect, isSelected: boolean): void {
|
||||
const count = line.led_count;
|
||||
if (count <= 0) return;
|
||||
|
||||
@@ -698,7 +754,7 @@ function _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function _drawArrow(ctx, x1, y1, x2, y2, reverse, color) {
|
||||
function _drawArrow(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, reverse: boolean, color: string): void {
|
||||
if (reverse) { [x1, y1, x2, y2] = [x2, y2, x1, y1]; }
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
const headLen = 8;
|
||||
@@ -716,8 +772,8 @@ function _drawArrow(ctx, x1, y1, x2, y2, reverse, color) {
|
||||
|
||||
/* ── Canvas interaction (drag monitors) ─────────────────────── */
|
||||
|
||||
function _initCanvasHandlers() {
|
||||
const canvas = document.getElementById('advcal-canvas');
|
||||
function _initCanvasHandlers(): void {
|
||||
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.oncontextmenu = (e) => e.preventDefault();
|
||||
@@ -848,7 +904,7 @@ function _initCanvasHandlers() {
|
||||
};
|
||||
}
|
||||
|
||||
function _pointNearLine(px, py, x1, y1, x2, y2, threshold) {
|
||||
function _pointNearLine(px: number, py: number, x1: number, y1: number, x2: number, y2: number, threshold: number): boolean {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
@@ -10,17 +10,17 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { TagInput } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { TagInput } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
|
||||
let _audioSourceTagsInput = null;
|
||||
let _audioSourceTagsInput: TagInput | null = null;
|
||||
|
||||
class AudioSourceModal extends Modal {
|
||||
constructor() { super('audio-source-modal'); }
|
||||
@@ -31,13 +31,13 @@ class AudioSourceModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('audio-source-name').value,
|
||||
description: document.getElementById('audio-source-description').value,
|
||||
type: document.getElementById('audio-source-type').value,
|
||||
device: document.getElementById('audio-source-device').value,
|
||||
audioTemplate: document.getElementById('audio-source-audio-template').value,
|
||||
parent: document.getElementById('audio-source-parent').value,
|
||||
channel: document.getElementById('audio-source-channel').value,
|
||||
name: (document.getElementById('audio-source-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('audio-source-description') as HTMLInputElement).value,
|
||||
type: (document.getElementById('audio-source-type') as HTMLSelectElement).value,
|
||||
device: (document.getElementById('audio-source-device') as HTMLSelectElement).value,
|
||||
audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value,
|
||||
parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
|
||||
channel: (document.getElementById('audio-source-channel') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -46,48 +46,48 @@ class AudioSourceModal extends Modal {
|
||||
const audioSourceModal = new AudioSourceModal();
|
||||
|
||||
// ── EntitySelect instances for audio source editor ──
|
||||
let _asTemplateEntitySelect = null;
|
||||
let _asDeviceEntitySelect = null;
|
||||
let _asParentEntitySelect = null;
|
||||
let _asTemplateEntitySelect: EntitySelect | null = null;
|
||||
let _asDeviceEntitySelect: EntitySelect | null = null;
|
||||
let _asParentEntitySelect: EntitySelect | null = null;
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
export async function showAudioSourceModal(sourceType, editData) {
|
||||
export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit
|
||||
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel')
|
||||
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
|
||||
|
||||
document.getElementById('audio-source-modal-title').innerHTML = `${ICON_MUSIC} ${t(titleKey)}`;
|
||||
document.getElementById('audio-source-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('audio-source-error').style.display = 'none';
|
||||
(document.getElementById('audio-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
|
||||
(document.getElementById('audio-source-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const typeSelect = document.getElementById('audio-source-type');
|
||||
const typeSelect = document.getElementById('audio-source-type') as HTMLSelectElement;
|
||||
typeSelect.value = isEdit ? editData.source_type : sourceType;
|
||||
typeSelect.disabled = isEdit; // can't change type after creation
|
||||
|
||||
onAudioSourceTypeChange();
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('audio-source-name').value = editData.name || '';
|
||||
document.getElementById('audio-source-description').value = editData.description || '';
|
||||
(document.getElementById('audio-source-name') as HTMLInputElement).value = editData.name || '';
|
||||
(document.getElementById('audio-source-description') as HTMLInputElement).value = editData.description || '';
|
||||
|
||||
if (editData.source_type === 'multichannel') {
|
||||
_loadAudioTemplates(editData.audio_template_id);
|
||||
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
||||
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
|
||||
await _loadAudioDevices();
|
||||
_selectAudioDevice(editData.device_index, editData.is_loopback);
|
||||
} else {
|
||||
_loadMultichannelSources(editData.audio_source_id);
|
||||
document.getElementById('audio-source-channel').value = editData.channel || 'mono';
|
||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('audio-source-name').value = '';
|
||||
document.getElementById('audio-source-description').value = '';
|
||||
(document.getElementById('audio-source-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('audio-source-description') as HTMLInputElement).value = '';
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
_loadAudioTemplates();
|
||||
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
||||
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
|
||||
await _loadAudioDevices();
|
||||
} else {
|
||||
_loadMultichannelSources();
|
||||
@@ -108,19 +108,19 @@ export async function closeAudioSourceModal() {
|
||||
}
|
||||
|
||||
export function onAudioSourceTypeChange() {
|
||||
const type = document.getElementById('audio-source-type').value;
|
||||
document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none';
|
||||
document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none';
|
||||
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
(document.getElementById('audio-source-multichannel-section') as HTMLElement).style.display = type === 'multichannel' ? '' : 'none';
|
||||
(document.getElementById('audio-source-mono-section') as HTMLElement).style.display = type === 'mono' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveAudioSource() {
|
||||
const id = document.getElementById('audio-source-id').value;
|
||||
const name = document.getElementById('audio-source-name').value.trim();
|
||||
const sourceType = document.getElementById('audio-source-type').value;
|
||||
const description = document.getElementById('audio-source-description').value.trim() || null;
|
||||
const errorEl = document.getElementById('audio-source-error');
|
||||
const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
|
||||
const errorEl = document.getElementById('audio-source-error') as HTMLElement;
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('audio_source.error.name_required');
|
||||
@@ -128,17 +128,17 @@ export async function saveAudioSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
|
||||
const payload: any = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
const deviceVal = document.getElementById('audio-source-device').value || '-1:1';
|
||||
const deviceVal = (document.getElementById('audio-source-device') as HTMLSelectElement).value || '-1:1';
|
||||
const [devIdx, devLoop] = deviceVal.split(':');
|
||||
payload.device_index = parseInt(devIdx) || -1;
|
||||
payload.is_loopback = devLoop !== '0';
|
||||
payload.audio_template_id = document.getElementById('audio-source-audio-template').value || null;
|
||||
payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null;
|
||||
} else {
|
||||
payload.audio_source_id = document.getElementById('audio-source-parent').value;
|
||||
payload.channel = document.getElementById('audio-source-channel').value;
|
||||
payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
|
||||
payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -157,7 +157,7 @@ export async function saveAudioSource() {
|
||||
audioSourceModal.forceClose();
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
@@ -165,13 +165,13 @@ export async function saveAudioSource() {
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
export async function editAudioSource(sourceId) {
|
||||
export async function editAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
const data = await resp.json();
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export async function editAudioSource(sourceId) {
|
||||
|
||||
// ── Clone ─────────────────────────────────────────────────────
|
||||
|
||||
export async function cloneAudioSource(sourceId) {
|
||||
export async function cloneAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
@@ -187,7 +187,7 @@ export async function cloneAudioSource(sourceId) {
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export async function cloneAudioSource(sourceId) {
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteAudioSource(sourceId) {
|
||||
export async function deleteAudioSource(sourceId: any) {
|
||||
const confirmed = await showConfirm(t('audio_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function deleteAudioSource(sourceId) {
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export async function deleteAudioSource(sourceId) {
|
||||
// ── Refresh devices ───────────────────────────────────────────
|
||||
|
||||
export async function refreshAudioDevices() {
|
||||
const btn = document.getElementById('audio-source-refresh-devices');
|
||||
const btn = document.getElementById('audio-source-refresh-devices') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await _loadAudioDevices();
|
||||
@@ -242,13 +242,13 @@ async function _loadAudioDevices() {
|
||||
}
|
||||
|
||||
function _filterDevicesBySelectedTemplate() {
|
||||
const select = document.getElementById('audio-source-device');
|
||||
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
const prevOption = select.options[select.selectedIndex];
|
||||
const prevName = prevOption ? prevOption.textContent : '';
|
||||
|
||||
const templateId = (document.getElementById('audio-source-audio-template') || {}).value;
|
||||
const templateId = ((document.getElementById('audio-source-audio-template') as HTMLSelectElement | null) || { value: '' } as any).value;
|
||||
const templates = _cachedAudioTemplates || [];
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
const engineType = template ? template.engine_type : null;
|
||||
@@ -262,7 +262,7 @@ function _filterDevicesBySelectedTemplate() {
|
||||
}
|
||||
}
|
||||
|
||||
select.innerHTML = devices.map(d => {
|
||||
select.innerHTML = devices.map((d: any) => {
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(d.name)}</option>`;
|
||||
}).join('');
|
||||
@@ -272,7 +272,7 @@ function _filterDevicesBySelectedTemplate() {
|
||||
}
|
||||
|
||||
if (prevName) {
|
||||
const match = Array.from(select.options).find(o => o.textContent === prevName);
|
||||
const match = Array.from(select.options).find((o: HTMLOptionElement) => o.textContent === prevName);
|
||||
if (match) select.value = match.value;
|
||||
}
|
||||
|
||||
@@ -280,27 +280,27 @@ function _filterDevicesBySelectedTemplate() {
|
||||
if (devices.length > 0) {
|
||||
_asDeviceEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => devices.map(d => ({
|
||||
getItems: () => devices.map((d: any) => ({
|
||||
value: `${d.index}:${d.is_loopback ? '1' : '0'}`,
|
||||
label: d.name,
|
||||
icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT,
|
||||
desc: d.is_loopback ? 'Loopback' : 'Input',
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _selectAudioDevice(deviceIndex, isLoopback) {
|
||||
const select = document.getElementById('audio-source-device');
|
||||
function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
|
||||
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
|
||||
const opt = Array.from(select.options).find(o => o.value === val);
|
||||
const opt = Array.from(select.options).find((o: HTMLOptionElement) => o.value === val);
|
||||
if (opt) select.value = val;
|
||||
}
|
||||
|
||||
function _loadMultichannelSources(selectedId) {
|
||||
const select = document.getElementById('audio-source-parent');
|
||||
function _loadMultichannelSources(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||||
select.innerHTML = multichannel.map(s =>
|
||||
@@ -311,18 +311,18 @@ function _loadMultichannelSources(selectedId) {
|
||||
if (multichannel.length > 0) {
|
||||
_asParentEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => multichannel.map(s => ({
|
||||
getItems: () => multichannel.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getAudioSourceIcon('multichannel'),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _loadAudioTemplates(selectedId) {
|
||||
const select = document.getElementById('audio-source-audio-template');
|
||||
function _loadAudioTemplates(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const templates = _cachedAudioTemplates || [];
|
||||
select.innerHTML = templates.map(t =>
|
||||
@@ -333,14 +333,14 @@ function _loadAudioTemplates(selectedId) {
|
||||
if (templates.length > 0) {
|
||||
_asTemplateEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => templates.map(tmpl => ({
|
||||
getItems: () => templates.map((tmpl: any) => ({
|
||||
value: tmpl.id,
|
||||
label: tmpl.name,
|
||||
icon: ICON_AUDIO_TEMPLATE,
|
||||
desc: tmpl.engine_type.toUpperCase(),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,15 +350,15 @@ const NUM_BANDS = 64;
|
||||
const PEAK_DECAY = 0.02; // peak drop per frame
|
||||
const BEAT_FLASH_DECAY = 0.06; // beat flash fade per frame
|
||||
|
||||
let _testAudioWs = null;
|
||||
let _testAudioAnimFrame = null;
|
||||
let _testAudioLatest = null;
|
||||
let _testAudioWs: WebSocket | null = null;
|
||||
let _testAudioAnimFrame: number | null = null;
|
||||
let _testAudioLatest: any = null;
|
||||
let _testAudioPeaks = new Float32Array(NUM_BANDS);
|
||||
let _testBeatFlash = 0;
|
||||
|
||||
const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true });
|
||||
|
||||
export function testAudioSource(sourceId) {
|
||||
export function testAudioSource(sourceId: any) {
|
||||
const statusEl = document.getElementById('audio-test-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = t('audio_source.test.connecting');
|
||||
@@ -377,7 +377,7 @@ export function testAudioSource(sourceId) {
|
||||
testAudioModal.open();
|
||||
|
||||
// Size canvas to container
|
||||
const canvas = document.getElementById('audio-test-canvas');
|
||||
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement;
|
||||
_sizeCanvas(canvas);
|
||||
|
||||
// Connect WebSocket
|
||||
@@ -433,7 +433,7 @@ function _cleanupTest() {
|
||||
_testAudioLatest = null;
|
||||
}
|
||||
|
||||
function _sizeCanvas(canvas) {
|
||||
function _sizeCanvas(canvas: HTMLCanvasElement) {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
@@ -450,7 +450,7 @@ function _renderLoop() {
|
||||
}
|
||||
|
||||
function _renderAudioSpectrum() {
|
||||
const canvas = document.getElementById('audio-test-canvas');
|
||||
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -2,24 +2,25 @@
|
||||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
||||
*/
|
||||
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateTabBadge } from './tabs.js';
|
||||
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 } from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { getBaseOrigin } from './settings.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { csScenes, createSceneCard } from './scene-presets.js';
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { updateTabBadge } from './tabs.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 } 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 { getBaseOrigin } from './settings.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { attachProcessPicker } from '../core/process-picker.ts';
|
||||
import { csScenes, createSceneCard } from './scene-presets.ts';
|
||||
import type { Automation } from '../types.ts';
|
||||
|
||||
let _automationTagsInput = null;
|
||||
let _automationTagsInput: any = null;
|
||||
|
||||
class AutomationEditorModal extends Modal {
|
||||
constructor() { super('automation-editor-modal'); }
|
||||
@@ -30,13 +31,13 @@ class AutomationEditorModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('automation-editor-name').value,
|
||||
enabled: document.getElementById('automation-editor-enabled').checked.toString(),
|
||||
logic: document.getElementById('automation-editor-logic').value,
|
||||
name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
|
||||
enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
|
||||
logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
|
||||
conditions: JSON.stringify(getAutomationEditorConditions()),
|
||||
scenePresetId: document.getElementById('automation-scene-id').value,
|
||||
deactivationMode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
|
||||
scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
|
||||
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
|
||||
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -45,33 +46,33 @@ class AutomationEditorModal extends Modal {
|
||||
const automationModal = new AutomationEditorModal();
|
||||
|
||||
// ── Bulk action handlers ──
|
||||
async function _bulkEnableAutomations(ids) {
|
||||
async function _bulkEnableAutomations(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
||||
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} enabled`, 'warning');
|
||||
else showToast(t('automations.updated'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
}
|
||||
|
||||
async function _bulkDisableAutomations(ids) {
|
||||
async function _bulkDisableAutomations(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
||||
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} disabled`, 'warning');
|
||||
else showToast(t('automations.updated'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
}
|
||||
|
||||
async function _bulkDeleteAutomations(ids) {
|
||||
async function _bulkDeleteAutomations(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/automations/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
||||
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('automations.deleted'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
@@ -82,13 +83,13 @@ const csAutomations = new CardSection('automations', { titleKey: 'automations.ti
|
||||
{ key: 'enable', labelKey: 'bulk.enable', icon: ICON_OK, handler: _bulkEnableAutomations },
|
||||
{ key: 'disable', labelKey: 'bulk.disable', icon: ICON_CIRCLE_OFF, handler: _bulkDisableAutomations },
|
||||
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteAutomations },
|
||||
] });
|
||||
] } as any);
|
||||
|
||||
/* ── Condition logic IconSelect ───────────────────────────────── */
|
||||
|
||||
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
let _conditionLogicIconSelect = null;
|
||||
let _conditionLogicIconSelect: any = null;
|
||||
|
||||
function _ensureConditionLogicIconSelect() {
|
||||
const sel = document.getElementById('automation-editor-logic');
|
||||
@@ -98,7 +99,7 @@ function _ensureConditionLogicIconSelect() {
|
||||
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
|
||||
];
|
||||
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
|
||||
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
|
||||
}
|
||||
|
||||
// Re-render automations when language changes (only if tab is active)
|
||||
@@ -130,7 +131,7 @@ export async function loadAutomations() {
|
||||
const activeCount = automations.filter(a => a.is_active).length;
|
||||
updateTabBadge('automations', activeCount);
|
||||
renderAutomations(automations, sceneMap);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load automations:', error);
|
||||
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
||||
@@ -140,7 +141,7 @@ export async function loadAutomations() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutomations(automations, sceneMap) {
|
||||
function renderAutomations(automations: any, sceneMap: any) {
|
||||
const container = document.getElementById('automations-content');
|
||||
|
||||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||||
@@ -162,7 +163,7 @@ function renderAutomations(automations, sceneMap) {
|
||||
}
|
||||
}
|
||||
|
||||
function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
|
||||
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
|
||||
|
||||
@@ -252,15 +253,15 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAutomationEditor(automationId, cloneData) {
|
||||
export async function openAutomationEditor(automationId?: any, cloneData?: any) {
|
||||
const modal = document.getElementById('automation-editor-modal');
|
||||
const titleEl = document.getElementById('automation-editor-title');
|
||||
const idInput = document.getElementById('automation-editor-id');
|
||||
const nameInput = document.getElementById('automation-editor-name');
|
||||
const enabledInput = document.getElementById('automation-editor-enabled');
|
||||
const logicSelect = document.getElementById('automation-editor-logic');
|
||||
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
|
||||
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
|
||||
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
|
||||
const condList = document.getElementById('automation-conditions-list');
|
||||
const errorEl = document.getElementById('automation-editor-error');
|
||||
const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
|
||||
|
||||
errorEl.style.display = 'none';
|
||||
condList.innerHTML = '';
|
||||
@@ -274,11 +275,11 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
} catch { /* use cached */ }
|
||||
|
||||
// Reset deactivation mode
|
||||
document.getElementById('automation-deactivation-mode').value = 'none';
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
|
||||
document.getElementById('automation-fallback-scene-group').style.display = 'none';
|
||||
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
|
||||
|
||||
let _editorTags = [];
|
||||
let _editorTags: any[] = [];
|
||||
|
||||
if (automationId) {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
|
||||
@@ -302,12 +303,12 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
|
||||
// Deactivation mode
|
||||
const deactMode = automation.deactivation_mode || 'none';
|
||||
document.getElementById('automation-deactivation-mode').value = deactMode;
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = deactMode;
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
|
||||
_editorTags = automation.tags || [];
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
}
|
||||
@@ -330,7 +331,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
|
||||
|
||||
const cloneDeactMode = cloneData.deactivation_mode || 'none';
|
||||
document.getElementById('automation-deactivation-mode').value = cloneDeactMode;
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = cloneDeactMode;
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
|
||||
@@ -347,14 +348,14 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
}
|
||||
|
||||
// Wire up deactivation mode change
|
||||
document.getElementById('automation-deactivation-mode').onchange = _onDeactivationModeChange;
|
||||
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
|
||||
|
||||
automationModal.open();
|
||||
modal.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = t(el.getAttribute('data-i18n'));
|
||||
});
|
||||
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
(el as HTMLInputElement).placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
});
|
||||
|
||||
// Tags
|
||||
@@ -366,8 +367,8 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
}
|
||||
|
||||
function _onDeactivationModeChange() {
|
||||
const mode = document.getElementById('automation-deactivation-mode').value;
|
||||
document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none';
|
||||
const mode = (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value;
|
||||
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = mode === 'fallback_scene' ? '' : 'none';
|
||||
}
|
||||
|
||||
export async function closeAutomationEditorModal() {
|
||||
@@ -376,8 +377,8 @@ export async function closeAutomationEditorModal() {
|
||||
|
||||
// ===== Scene selector (EntitySelect) =====
|
||||
|
||||
let _sceneEntitySelect = null;
|
||||
let _fallbackSceneEntitySelect = null;
|
||||
let _sceneEntitySelect: any = null;
|
||||
let _fallbackSceneEntitySelect: any = null;
|
||||
|
||||
function _getSceneItems() {
|
||||
return (scenePresetsCache.data || []).map(s => ({
|
||||
@@ -387,8 +388,8 @@ function _getSceneItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
function _initSceneSelector(selectId, selectedId) {
|
||||
const sel = document.getElementById(selectId);
|
||||
function _initSceneSelector(selectId: any, selectedId: any) {
|
||||
const sel = document.getElementById(selectId) as HTMLSelectElement;
|
||||
// Populate <select> with scene options
|
||||
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
|
||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
||||
@@ -406,7 +407,7 @@ function _initSceneSelector(selectId, selectedId) {
|
||||
placeholder: t('automations.scene.search_placeholder'),
|
||||
allowNone: true,
|
||||
noneLabel: t('automations.scene.none_selected'),
|
||||
});
|
||||
} as any);
|
||||
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
|
||||
}
|
||||
|
||||
@@ -416,7 +417,7 @@ const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
|
||||
const DEACT_MODE_ICONS = {
|
||||
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
|
||||
};
|
||||
let _deactivationModeIconSelect = null;
|
||||
let _deactivationModeIconSelect: any = null;
|
||||
|
||||
function _ensureDeactivationModeIconSelect() {
|
||||
const sel = document.getElementById('automation-deactivation-mode');
|
||||
@@ -427,7 +428,7 @@ function _ensureDeactivationModeIconSelect() {
|
||||
label: t(`automations.deactivation_mode.${k}`),
|
||||
desc: t(`automations.deactivation_mode.${k}.desc`),
|
||||
}));
|
||||
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||
}
|
||||
|
||||
// ===== Condition editor =====
|
||||
@@ -466,7 +467,7 @@ function _buildConditionTypeItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
function addAutomationConditionRow(condition) {
|
||||
function addAutomationConditionRow(condition: any) {
|
||||
const list = document.getElementById('automation-conditions-list');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'automation-condition-row';
|
||||
@@ -482,17 +483,17 @@ function addAutomationConditionRow(condition) {
|
||||
<div class="condition-fields-container"></div>
|
||||
`;
|
||||
|
||||
const typeSelect = row.querySelector('.condition-type-select');
|
||||
const container = row.querySelector('.condition-fields-container');
|
||||
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
|
||||
const container = row.querySelector('.condition-fields-container') as HTMLElement;
|
||||
|
||||
// Attach IconSelect to the condition type dropdown
|
||||
const condIconSelect = new IconSelect({
|
||||
target: typeSelect,
|
||||
items: _buildConditionTypeItems(),
|
||||
columns: 4,
|
||||
});
|
||||
} as any);
|
||||
|
||||
function renderFields(type, data) {
|
||||
function renderFields(type: any, data: any) {
|
||||
if (type === 'always') {
|
||||
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
|
||||
return;
|
||||
@@ -626,7 +627,7 @@ function addAutomationConditionRow(condition) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textarea = container.querySelector('.condition-apps');
|
||||
const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement;
|
||||
attachProcessPicker(container, textarea);
|
||||
|
||||
// Attach IconSelect to match type
|
||||
@@ -636,7 +637,7 @@ function addAutomationConditionRow(condition) {
|
||||
target: matchSel,
|
||||
items: _buildMatchTypeItems(),
|
||||
columns: 2,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,9 +653,9 @@ function addAutomationConditionRow(condition) {
|
||||
|
||||
function getAutomationEditorConditions() {
|
||||
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
|
||||
const conditions = [];
|
||||
const conditions: any[] = [];
|
||||
rows.forEach(row => {
|
||||
const typeSelect = row.querySelector('.condition-type-select');
|
||||
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
|
||||
const condType = typeSelect ? typeSelect.value : 'application';
|
||||
if (condType === 'always') {
|
||||
conditions.push({ condition_type: 'always' });
|
||||
@@ -663,35 +664,35 @@ function getAutomationEditorConditions() {
|
||||
} else if (condType === 'time_of_day') {
|
||||
conditions.push({
|
||||
condition_type: 'time_of_day',
|
||||
start_time: row.querySelector('.condition-start-time').value || '00:00',
|
||||
end_time: row.querySelector('.condition-end-time').value || '23:59',
|
||||
start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00',
|
||||
end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59',
|
||||
});
|
||||
} else if (condType === 'system_idle') {
|
||||
conditions.push({
|
||||
condition_type: 'system_idle',
|
||||
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
|
||||
when_idle: row.querySelector('.condition-when-idle').value === 'true',
|
||||
idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5,
|
||||
when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true',
|
||||
});
|
||||
} else if (condType === 'display_state') {
|
||||
conditions.push({
|
||||
condition_type: 'display_state',
|
||||
state: row.querySelector('.condition-display-state').value || 'on',
|
||||
state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on',
|
||||
});
|
||||
} else if (condType === 'mqtt') {
|
||||
conditions.push({
|
||||
condition_type: 'mqtt',
|
||||
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
|
||||
payload: row.querySelector('.condition-mqtt-payload').value,
|
||||
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
|
||||
topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(),
|
||||
payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value,
|
||||
match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
|
||||
});
|
||||
} else if (condType === 'webhook') {
|
||||
const tokenInput = row.querySelector('.condition-webhook-token');
|
||||
const cond = { condition_type: 'webhook' };
|
||||
const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement;
|
||||
const cond: any = { condition_type: 'webhook' };
|
||||
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
||||
conditions.push(cond);
|
||||
} else {
|
||||
const matchType = row.querySelector('.condition-match-type').value;
|
||||
const appsText = row.querySelector('.condition-apps').value.trim();
|
||||
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
||||
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
||||
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
|
||||
conditions.push({ condition_type: 'application', apps, match_type: matchType });
|
||||
}
|
||||
@@ -700,10 +701,10 @@ function getAutomationEditorConditions() {
|
||||
}
|
||||
|
||||
export async function saveAutomationEditor() {
|
||||
const idInput = document.getElementById('automation-editor-id');
|
||||
const nameInput = document.getElementById('automation-editor-name');
|
||||
const enabledInput = document.getElementById('automation-editor-enabled');
|
||||
const logicSelect = document.getElementById('automation-editor-logic');
|
||||
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
|
||||
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
|
||||
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
@@ -716,9 +717,9 @@ export async function saveAutomationEditor() {
|
||||
enabled: enabledInput.checked,
|
||||
condition_logic: logicSelect.value,
|
||||
conditions: getAutomationEditorConditions(),
|
||||
scene_preset_id: document.getElementById('automation-scene-id').value || null,
|
||||
deactivation_mode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
|
||||
scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null,
|
||||
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
|
||||
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
|
||||
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
@@ -740,13 +741,13 @@ export async function saveAutomationEditor() {
|
||||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
automationModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAutomationEnabled(automationId, enable) {
|
||||
export async function toggleAutomationEnabled(automationId: any, enable: any) {
|
||||
try {
|
||||
const action = enable ? 'enable' : 'disable';
|
||||
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
|
||||
@@ -758,14 +759,14 @@ export async function toggleAutomationEnabled(automationId, enable) {
|
||||
}
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function copyWebhookUrl(btn) {
|
||||
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url');
|
||||
export function copyWebhookUrl(btn: any) {
|
||||
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement;
|
||||
if (!input || !input.value) return;
|
||||
const onCopied = () => {
|
||||
const orig = btn.textContent;
|
||||
@@ -781,19 +782,19 @@ export function copyWebhookUrl(btn) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneAutomation(automationId) {
|
||||
export async function cloneAutomation(automationId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/automations/${automationId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load automation');
|
||||
const automation = await resp.json();
|
||||
openAutomationEditor(null, automation);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('automations.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAutomation(automationId, automationName) {
|
||||
export async function deleteAutomation(automationId: any, automationName: any) {
|
||||
const msg = t('automations.delete.confirm').replace('{name}', automationName);
|
||||
const confirmed = await showConfirm(msg);
|
||||
if (!confirmed) return;
|
||||
@@ -809,7 +810,7 @@ export async function deleteAutomation(automationId, automationName) {
|
||||
showToast(t('automations.deleted'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@@ -4,15 +4,16 @@
|
||||
|
||||
import {
|
||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
|
||||
import { startCSSOverlay, stopCSSOverlay } from './color-strips.js';
|
||||
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
|
||||
import { startCSSOverlay, stopCSSOverlay } from './color-strips.ts';
|
||||
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts';
|
||||
import type { Calibration } from '../types.ts';
|
||||
|
||||
/* ── CalibrationModal subclass ────────────────────────────────── */
|
||||
|
||||
@@ -23,35 +24,35 @@ class CalibrationModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
start_position: this.$('cal-start-position').value,
|
||||
layout: this.$('cal-layout').value,
|
||||
offset: this.$('cal-offset').value,
|
||||
top: this.$('cal-top-leds').value,
|
||||
right: this.$('cal-right-leds').value,
|
||||
bottom: this.$('cal-bottom-leds').value,
|
||||
left: this.$('cal-left-leds').value,
|
||||
start_position: (this.$('cal-start-position') as HTMLSelectElement).value,
|
||||
layout: (this.$('cal-layout') as HTMLSelectElement).value,
|
||||
offset: (this.$('cal-offset') as HTMLInputElement).value,
|
||||
top: (this.$('cal-top-leds') as HTMLInputElement).value,
|
||||
right: (this.$('cal-right-leds') as HTMLInputElement).value,
|
||||
bottom: (this.$('cal-bottom-leds') as HTMLInputElement).value,
|
||||
left: (this.$('cal-left-leds') as HTMLInputElement).value,
|
||||
spans: JSON.stringify(window.edgeSpans),
|
||||
skip_start: this.$('cal-skip-start').value,
|
||||
skip_end: this.$('cal-skip-end').value,
|
||||
border_width: this.$('cal-border-width').value,
|
||||
led_count: this.$('cal-css-led-count').value,
|
||||
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
||||
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
||||
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
||||
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
closeTutorial();
|
||||
if (_isCSS()) {
|
||||
const cssId = document.getElementById('calibration-css-id')?.value;
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||
if (_overlayStartedHere && cssId) {
|
||||
stopCSSOverlay(cssId);
|
||||
_overlayStartedHere = false;
|
||||
}
|
||||
_clearCSSTestMode();
|
||||
document.getElementById('calibration-css-id').value = '';
|
||||
(document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
|
||||
const testGroup = document.getElementById('calibration-css-test-group');
|
||||
if (testGroup) testGroup.style.display = 'none';
|
||||
} else {
|
||||
const deviceId = this.$('calibration-device-id').value;
|
||||
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
||||
if (deviceId) clearTestMode(deviceId);
|
||||
}
|
||||
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
|
||||
@@ -62,26 +63,26 @@ class CalibrationModal extends Modal {
|
||||
|
||||
const calibModal = new CalibrationModal();
|
||||
|
||||
let _dragRaf = null;
|
||||
let _previewRaf = null;
|
||||
let _dragRaf: number | null = null;
|
||||
let _previewRaf: number | null = null;
|
||||
let _overlayStartedHere = false;
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function _isCSS() {
|
||||
return !!(document.getElementById('calibration-css-id')?.value);
|
||||
return !!((document.getElementById('calibration-css-id') as HTMLInputElement)?.value);
|
||||
}
|
||||
|
||||
function _cssStateKey() {
|
||||
return `css_${document.getElementById('calibration-css-id').value}`;
|
||||
return `css_${(document.getElementById('calibration-css-id') as HTMLInputElement).value}`;
|
||||
}
|
||||
|
||||
async function _clearCSSTestMode() {
|
||||
const cssId = document.getElementById('calibration-css-id')?.value;
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||
const stateKey = _cssStateKey();
|
||||
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
|
||||
calibrationTestState[stateKey] = new Set();
|
||||
const testDeviceId = document.getElementById('calibration-test-device')?.value;
|
||||
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
||||
if (!testDeviceId) return;
|
||||
try {
|
||||
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
@@ -93,13 +94,13 @@ async function _clearCSSTestMode() {
|
||||
}
|
||||
}
|
||||
|
||||
function _setOverlayBtnActive(active) {
|
||||
function _setOverlayBtnActive(active: any) {
|
||||
const btn = document.getElementById('calibration-overlay-btn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('active', active);
|
||||
}
|
||||
|
||||
async function _checkOverlayStatus(cssId) {
|
||||
async function _checkOverlayStatus(cssId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (resp.ok) {
|
||||
@@ -110,7 +111,7 @@ async function _checkOverlayStatus(cssId) {
|
||||
}
|
||||
|
||||
export async function toggleCalibrationOverlay() {
|
||||
const cssId = document.getElementById('calibration-css-id')?.value;
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||
if (!cssId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
@@ -125,7 +126,7 @@ export async function toggleCalibrationOverlay() {
|
||||
_setOverlayBtnActive(true);
|
||||
_overlayStartedHere = true;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle calibration overlay:', err);
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export async function toggleCalibrationOverlay() {
|
||||
|
||||
/* ── Public API (exported names unchanged) ────────────────────── */
|
||||
|
||||
export async function showCalibration(deviceId) {
|
||||
export async function showCalibration(deviceId: any) {
|
||||
try {
|
||||
const [response, displays] = await Promise.all([
|
||||
fetchWithAuth(`/devices/${deviceId}`),
|
||||
@@ -145,34 +146,34 @@ export async function showCalibration(deviceId) {
|
||||
const device = await response.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
const preview = document.querySelector('.calibration-preview');
|
||||
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
||||
const displayIndex = device.settings?.display_index ?? 0;
|
||||
const display = displays.find(d => d.index === displayIndex);
|
||||
const display = displays.find((d: any) => d.index === displayIndex);
|
||||
if (display && display.width && display.height) {
|
||||
preview.style.aspectRatio = `${display.width} / ${display.height}`;
|
||||
} else {
|
||||
preview.style.aspectRatio = '';
|
||||
}
|
||||
|
||||
document.getElementById('calibration-device-id').value = device.id;
|
||||
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
|
||||
document.getElementById('cal-css-led-count-group').style.display = 'none';
|
||||
document.getElementById('calibration-overlay-btn').style.display = 'none';
|
||||
(document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id;
|
||||
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count;
|
||||
(document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none';
|
||||
(document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none';
|
||||
|
||||
document.getElementById('cal-start-position').value = calibration.start_position;
|
||||
document.getElementById('cal-layout').value = calibration.layout;
|
||||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
|
||||
(document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout;
|
||||
(document.getElementById('cal-offset') as HTMLInputElement).value = calibration.offset || 0;
|
||||
|
||||
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
||||
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
||||
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
||||
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
||||
(document.getElementById('cal-top-leds') as HTMLInputElement).value = calibration.leds_top || 0;
|
||||
(document.getElementById('cal-right-leds') as HTMLInputElement).value = calibration.leds_right || 0;
|
||||
(document.getElementById('cal-bottom-leds') as HTMLInputElement).value = calibration.leds_bottom || 0;
|
||||
(document.getElementById('cal-left-leds') as HTMLInputElement).value = calibration.leds_left || 0;
|
||||
|
||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
(document.getElementById('cal-skip-start') as HTMLInputElement).value = calibration.skip_leds_start || 0;
|
||||
(document.getElementById('cal-skip-end') as HTMLInputElement).value = calibration.skip_leds_end || 0;
|
||||
updateOffsetSkipLock();
|
||||
|
||||
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
||||
|
||||
window.edgeSpans = {
|
||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||
@@ -209,7 +210,7 @@ export async function showCalibration(deviceId) {
|
||||
}
|
||||
window._calibrationResizeObserver.observe(preview);
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load calibration:', error);
|
||||
showToast(t('calibration.error.load_failed'), 'error');
|
||||
@@ -230,71 +231,70 @@ export async function closeCalibrationModal() {
|
||||
|
||||
/* ── CSS Calibration support ──────────────────────────────────── */
|
||||
|
||||
export async function showCSSCalibration(cssId) {
|
||||
export async function showCSSCalibration(cssId: any) {
|
||||
try {
|
||||
const [cssSources, devices] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
devicesCache.fetch().catch(() => []),
|
||||
]);
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
const source = cssSources.find((s: any) => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {
|
||||
}
|
||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||
|
||||
// Set CSS mode — clear device-id, set css-id
|
||||
document.getElementById('calibration-device-id').value = '';
|
||||
document.getElementById('calibration-css-id').value = cssId;
|
||||
(document.getElementById('calibration-device-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId;
|
||||
|
||||
// Populate device picker for edge test
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device');
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement;
|
||||
testDeviceSelect.innerHTML = '';
|
||||
devices.forEach(d => {
|
||||
devices.forEach((d: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
opt.textContent = d.name;
|
||||
testDeviceSelect.appendChild(opt);
|
||||
});
|
||||
const testGroup = document.getElementById('calibration-css-test-group');
|
||||
const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
|
||||
testGroup.style.display = devices.length ? '' : 'none';
|
||||
|
||||
// Pre-select device: 1) LED count match, 2) last remembered, 3) first
|
||||
if (devices.length) {
|
||||
const rememberedId = localStorage.getItem('css_calibration_test_device');
|
||||
let selected = null;
|
||||
let selected: any = null;
|
||||
if (source.led_count > 0) {
|
||||
selected = devices.find(d => d.led_count === source.led_count) || null;
|
||||
selected = devices.find((d: any) => d.led_count === source.led_count) || null;
|
||||
}
|
||||
if (!selected && rememberedId) {
|
||||
selected = devices.find(d => d.id === rememberedId) || null;
|
||||
selected = devices.find((d: any) => d.id === rememberedId) || null;
|
||||
}
|
||||
if (selected) testDeviceSelect.value = selected.id;
|
||||
testDeviceSelect.onchange = () => localStorage.setItem('css_calibration_test_device', testDeviceSelect.value);
|
||||
}
|
||||
|
||||
// Populate calibration fields
|
||||
const preview = document.querySelector('.calibration-preview');
|
||||
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
||||
preview.style.aspectRatio = '';
|
||||
document.getElementById('cal-device-led-count-inline').textContent = '—';
|
||||
const ledCountGroup = document.getElementById('cal-css-led-count-group');
|
||||
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = '—';
|
||||
const ledCountGroup = document.getElementById('cal-css-led-count-group') as HTMLElement;
|
||||
ledCountGroup.style.display = '';
|
||||
const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) +
|
||||
(calibration.leds_bottom || 0) + (calibration.leds_left || 0);
|
||||
document.getElementById('cal-css-led-count').value = source.led_count || calLeds || 0;
|
||||
(document.getElementById('cal-css-led-count') as HTMLInputElement).value = String(source.led_count || calLeds || 0);
|
||||
|
||||
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left';
|
||||
document.getElementById('cal-layout').value = calibration.layout || 'clockwise';
|
||||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position || 'bottom_left';
|
||||
(document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout || 'clockwise';
|
||||
(document.getElementById('cal-offset') as HTMLInputElement).value = String(calibration.offset || 0);
|
||||
|
||||
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
||||
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
||||
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
||||
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
||||
(document.getElementById('cal-top-leds') as HTMLInputElement).value = String(calibration.leds_top || 0);
|
||||
(document.getElementById('cal-right-leds') as HTMLInputElement).value = String(calibration.leds_right || 0);
|
||||
(document.getElementById('cal-bottom-leds') as HTMLInputElement).value = String(calibration.leds_bottom || 0);
|
||||
(document.getElementById('cal-left-leds') as HTMLInputElement).value = String(calibration.leds_left || 0);
|
||||
|
||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
(document.getElementById('cal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0);
|
||||
(document.getElementById('cal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0);
|
||||
updateOffsetSkipLock();
|
||||
|
||||
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
||||
|
||||
window.edgeSpans = {
|
||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||
@@ -312,7 +312,7 @@ export async function showCSSCalibration(cssId) {
|
||||
|
||||
// Show overlay toggle and check current status
|
||||
_overlayStartedHere = false;
|
||||
const overlayBtn = document.getElementById('calibration-overlay-btn');
|
||||
const overlayBtn = document.getElementById('calibration-overlay-btn') as HTMLElement;
|
||||
overlayBtn.style.display = '';
|
||||
_setOverlayBtnActive(false);
|
||||
_checkOverlayStatus(cssId);
|
||||
@@ -332,7 +332,7 @@ export async function showCSSCalibration(cssId) {
|
||||
}
|
||||
window._calibrationResizeObserver.observe(preview);
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load CSS calibration:', error);
|
||||
showToast(t('calibration.error.load_failed'), 'error');
|
||||
@@ -340,58 +340,58 @@ export async function showCSSCalibration(cssId) {
|
||||
}
|
||||
|
||||
export function updateOffsetSkipLock() {
|
||||
const offsetEl = document.getElementById('cal-offset');
|
||||
const skipStartEl = document.getElementById('cal-skip-start');
|
||||
const skipEndEl = document.getElementById('cal-skip-end');
|
||||
const hasOffset = parseInt(offsetEl.value || 0) > 0;
|
||||
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
|
||||
const offsetEl = document.getElementById('cal-offset') as HTMLInputElement;
|
||||
const skipStartEl = document.getElementById('cal-skip-start') as HTMLInputElement;
|
||||
const skipEndEl = document.getElementById('cal-skip-end') as HTMLInputElement;
|
||||
const hasOffset = parseInt(offsetEl.value || '0') > 0;
|
||||
const hasSkip = parseInt(skipStartEl.value || '0') > 0 || parseInt(skipEndEl.value || '0') > 0;
|
||||
skipStartEl.disabled = hasOffset;
|
||||
skipEndEl.disabled = hasOffset;
|
||||
offsetEl.disabled = hasSkip;
|
||||
}
|
||||
|
||||
export function updateCalibrationPreview() {
|
||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-right-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const totalEl = document.querySelector('.preview-screen-total');
|
||||
const total = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0') +
|
||||
parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0') +
|
||||
parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0') +
|
||||
parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0');
|
||||
const totalEl = document.querySelector('.preview-screen-total') as HTMLElement;
|
||||
const inCSS = _isCSS();
|
||||
const declaredCount = inCSS
|
||||
? parseInt(document.getElementById('cal-css-led-count').value || 0)
|
||||
: parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
||||
? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value || '0')
|
||||
: parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent || '0');
|
||||
if (inCSS) {
|
||||
document.getElementById('cal-device-led-count-inline').textContent = declaredCount || '—';
|
||||
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = String(declaredCount || '—');
|
||||
}
|
||||
// In device mode: calibration total must exactly equal device LED count
|
||||
// In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest)
|
||||
const mismatch = inCSS
|
||||
? (declaredCount > 0 && total > declaredCount)
|
||||
: (total !== declaredCount);
|
||||
document.getElementById('cal-total-leds-inline').innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total;
|
||||
(document.getElementById('cal-total-leds-inline') as HTMLElement).innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total;
|
||||
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
|
||||
|
||||
const startPos = document.getElementById('cal-start-position').value;
|
||||
const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
|
||||
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
|
||||
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
|
||||
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`) as HTMLElement;
|
||||
if (cornerEl) {
|
||||
if (corner === startPos) cornerEl.classList.add('active');
|
||||
else cornerEl.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
const direction = document.getElementById('cal-layout').value;
|
||||
const direction = (document.getElementById('cal-layout') as HTMLSelectElement).value;
|
||||
const dirIcon = document.getElementById('direction-icon');
|
||||
const dirLabel = document.getElementById('direction-label');
|
||||
if (dirIcon) dirIcon.innerHTML = direction === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
|
||||
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
||||
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||
const stateKey = _isCSS() ? _cssStateKey() : deviceId;
|
||||
const activeEdges = calibrationTestState[stateKey] || new Set();
|
||||
|
||||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement;
|
||||
if (!toggleEl) return;
|
||||
if (activeEdges.has(edge)) {
|
||||
const [r, g, b] = EDGE_TEST_COLORS[edge];
|
||||
@@ -404,9 +404,9 @@ export function updateCalibrationPreview() {
|
||||
});
|
||||
|
||||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||||
const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||||
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
|
||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||||
const count = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
|
||||
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`) as HTMLElement;
|
||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement;
|
||||
if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
|
||||
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
|
||||
});
|
||||
@@ -420,11 +420,11 @@ export function updateCalibrationPreview() {
|
||||
}
|
||||
|
||||
export function renderCalibrationCanvas() {
|
||||
const canvas = document.getElementById('calibration-preview-canvas');
|
||||
const canvas = document.getElementById('calibration-preview-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerRect = container!.getBoundingClientRect();
|
||||
if (containerRect.width === 0 || containerRect.height === 0) return;
|
||||
|
||||
const padX = 40;
|
||||
@@ -435,7 +435,7 @@ export function renderCalibrationCanvas() {
|
||||
const canvasH = containerRect.height + padY * 2;
|
||||
canvas.width = canvasW * dpr;
|
||||
canvas.height = canvasH * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||||
|
||||
@@ -444,20 +444,20 @@ export function renderCalibrationCanvas() {
|
||||
const cW = containerRect.width;
|
||||
const cH = containerRect.height;
|
||||
|
||||
const startPos = document.getElementById('cal-start-position').value;
|
||||
const layout = document.getElementById('cal-layout').value;
|
||||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||||
const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
|
||||
const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value;
|
||||
const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0');
|
||||
const calibration = {
|
||||
start_position: startPos,
|
||||
layout: layout,
|
||||
offset: offset,
|
||||
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
|
||||
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
|
||||
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
|
||||
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
|
||||
leds_top: parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0'),
|
||||
leds_right: parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0'),
|
||||
leds_bottom: parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0'),
|
||||
leds_left: parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0'),
|
||||
};
|
||||
const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0);
|
||||
const skipEnd = parseInt(document.getElementById('cal-skip-end').value || 0);
|
||||
const skipStart = parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0');
|
||||
const skipEnd = parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0');
|
||||
|
||||
const segments = buildSegments(calibration);
|
||||
if (segments.length === 0) return;
|
||||
@@ -477,7 +477,7 @@ export function renderCalibrationCanvas() {
|
||||
const edgeLenH = cW - 2 * cw;
|
||||
const edgeLenV = cH - 2 * ch;
|
||||
|
||||
const edgeGeometry = {
|
||||
const edgeGeometry: any = {
|
||||
top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
|
||||
bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
|
||||
left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
|
||||
@@ -485,7 +485,7 @@ export function renderCalibrationCanvas() {
|
||||
};
|
||||
|
||||
const toggleSize = 16;
|
||||
const axisPos = {
|
||||
const axisPos: any = {
|
||||
top: oy - toggleSize - 3,
|
||||
bottom: oy + cH + toggleSize + 3,
|
||||
left: ox - toggleSize - 3,
|
||||
@@ -493,14 +493,14 @@ export function renderCalibrationCanvas() {
|
||||
};
|
||||
|
||||
const arrowInset = 12;
|
||||
const arrowPos = {
|
||||
const arrowPos: any = {
|
||||
top: oy + ch + arrowInset,
|
||||
bottom: oy + cH - ch - arrowInset,
|
||||
left: ox + cw + arrowInset,
|
||||
right: ox + cW - cw - arrowInset,
|
||||
};
|
||||
|
||||
segments.forEach(seg => {
|
||||
segments.forEach((seg: any) => {
|
||||
const geo = edgeGeometry[seg.edge];
|
||||
if (!geo) return;
|
||||
|
||||
@@ -510,23 +510,23 @@ export function renderCalibrationCanvas() {
|
||||
const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start;
|
||||
const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1;
|
||||
const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart;
|
||||
const toEdgeLabel = (i) => {
|
||||
const toEdgeLabel = (i: number) => {
|
||||
if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||||
if (count <= 1) return edgeDisplayStart;
|
||||
return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange);
|
||||
};
|
||||
|
||||
const edgeBounds = new Set();
|
||||
const edgeBounds = new Set<number>();
|
||||
edgeBounds.add(0);
|
||||
if (count > 1) edgeBounds.add(count - 1);
|
||||
|
||||
const specialTicks = new Set();
|
||||
const specialTicks = new Set<number>();
|
||||
if (offset > 0 && totalLeds > 0) {
|
||||
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
|
||||
if (zeroPos < count) specialTicks.add(zeroPos);
|
||||
}
|
||||
|
||||
const labelsToShow = new Set([...specialTicks]);
|
||||
const labelsToShow = new Set<number>([...specialTicks]);
|
||||
|
||||
if (count > 2) {
|
||||
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
|
||||
@@ -541,12 +541,12 @@ export function renderCalibrationCanvas() {
|
||||
if (Math.floor(count / s) <= maxIntermediate) { step = s; break; }
|
||||
}
|
||||
|
||||
const tickPx = i => {
|
||||
const tickPx = (i: number) => {
|
||||
const f = i / (count - 1);
|
||||
return (seg.reverse ? (1 - f) : f) * edgeLen;
|
||||
};
|
||||
|
||||
const placed = [];
|
||||
const placed: number[] = [];
|
||||
specialTicks.forEach(i => placed.push(tickPx(i)));
|
||||
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
@@ -634,12 +634,12 @@ export function renderCalibrationCanvas() {
|
||||
|
||||
function updateSpanBars() {
|
||||
const spans = window.edgeSpans || {};
|
||||
const container = document.querySelector('.calibration-preview');
|
||||
const container = document.querySelector('.calibration-preview') as HTMLElement;
|
||||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||||
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
|
||||
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`) as HTMLElement;
|
||||
if (!bar) return;
|
||||
const span = spans[edge] || { start: 0, end: 1 };
|
||||
const edgeEl = bar.parentElement;
|
||||
const edgeEl = bar.parentElement as HTMLElement;
|
||||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||||
|
||||
if (isHorizontal) {
|
||||
@@ -653,7 +653,7 @@ function updateSpanBars() {
|
||||
}
|
||||
|
||||
if (!container) return;
|
||||
const toggle = container.querySelector(`.toggle-${edge}`);
|
||||
const toggle = container.querySelector(`.toggle-${edge}`) as HTMLElement;
|
||||
if (!toggle) return;
|
||||
if (isHorizontal) {
|
||||
const cornerW = 56;
|
||||
@@ -675,22 +675,22 @@ function initSpanDrag() {
|
||||
const MIN_SPAN = 0.05;
|
||||
|
||||
document.querySelectorAll('.edge-span-bar').forEach(bar => {
|
||||
const edge = bar.dataset.edge;
|
||||
const edge = (bar as HTMLElement).dataset.edge!;
|
||||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||||
|
||||
bar.addEventListener('click', e => e.stopPropagation());
|
||||
bar.addEventListener('click', (e: Event) => e.stopPropagation());
|
||||
|
||||
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
|
||||
handle.addEventListener('mousedown', e => {
|
||||
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||||
handle.addEventListener('mousedown', (e: Event) => {
|
||||
const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
|
||||
if (edgeLeds === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleType = handle.dataset.handle;
|
||||
const edgeEl = bar.parentElement;
|
||||
const handleType = (handle as HTMLElement).dataset.handle;
|
||||
const edgeEl = bar.parentElement as HTMLElement;
|
||||
const rect = edgeEl.getBoundingClientRect();
|
||||
|
||||
function onMouseMove(ev) {
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
const span = window.edgeSpans[edge];
|
||||
let fraction;
|
||||
if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width;
|
||||
@@ -722,22 +722,22 @@ function initSpanDrag() {
|
||||
});
|
||||
});
|
||||
|
||||
bar.addEventListener('mousedown', e => {
|
||||
if (e.target.classList.contains('edge-span-handle')) return;
|
||||
bar.addEventListener('mousedown', (e: Event) => {
|
||||
if ((e.target as HTMLElement).classList.contains('edge-span-handle')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const edgeEl = bar.parentElement;
|
||||
const edgeEl = bar.parentElement as HTMLElement;
|
||||
const rect = edgeEl.getBoundingClientRect();
|
||||
const span = window.edgeSpans[edge];
|
||||
const spanWidth = span.end - span.start;
|
||||
|
||||
let startFraction;
|
||||
if (isHorizontal) startFraction = (e.clientX - rect.left) / rect.width;
|
||||
else startFraction = (e.clientY - rect.top) / rect.height;
|
||||
if (isHorizontal) startFraction = ((e as MouseEvent).clientX - rect.left) / rect.width;
|
||||
else startFraction = ((e as MouseEvent).clientY - rect.top) / rect.height;
|
||||
const offsetInSpan = startFraction - span.start;
|
||||
|
||||
function onMouseMove(ev) {
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
let fraction;
|
||||
if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width;
|
||||
else fraction = (ev.clientY - rect.top) / rect.height;
|
||||
@@ -772,8 +772,8 @@ function initSpanDrag() {
|
||||
updateSpanBars();
|
||||
}
|
||||
|
||||
export function setStartPosition(position) {
|
||||
document.getElementById('cal-start-position').value = position;
|
||||
export function setStartPosition(position: any) {
|
||||
(document.getElementById('cal-start-position') as HTMLSelectElement).value = position;
|
||||
updateCalibrationPreview();
|
||||
}
|
||||
|
||||
@@ -783,20 +783,20 @@ export function toggleEdgeInputs() {
|
||||
}
|
||||
|
||||
export function toggleDirection() {
|
||||
const select = document.getElementById('cal-layout');
|
||||
const select = document.getElementById('cal-layout') as HTMLSelectElement;
|
||||
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
|
||||
updateCalibrationPreview();
|
||||
}
|
||||
|
||||
export async function toggleTestEdge(edge) {
|
||||
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||||
export async function toggleTestEdge(edge: any) {
|
||||
const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
|
||||
if (edgeLeds === 0) return;
|
||||
|
||||
const error = document.getElementById('calibration-error');
|
||||
const error = document.getElementById('calibration-error') as HTMLElement;
|
||||
|
||||
if (_isCSS()) {
|
||||
const cssId = document.getElementById('calibration-css-id').value;
|
||||
const testDeviceId = document.getElementById('calibration-test-device')?.value;
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
|
||||
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
||||
if (!testDeviceId) return;
|
||||
|
||||
const stateKey = _cssStateKey();
|
||||
@@ -804,8 +804,8 @@ export async function toggleTestEdge(edge) {
|
||||
if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge);
|
||||
else calibrationTestState[stateKey].add(edge);
|
||||
|
||||
const edges = {};
|
||||
calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
|
||||
const edges: any = {};
|
||||
calibrationTestState[stateKey].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; });
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
@@ -816,11 +816,11 @@ export async function toggleTestEdge(edge) {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle CSS test edge:', err);
|
||||
error.textContent = err.message || t('calibration.error.test_toggle_failed');
|
||||
@@ -829,14 +829,14 @@ export async function toggleTestEdge(edge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
|
||||
|
||||
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
|
||||
else calibrationTestState[deviceId].add(edge);
|
||||
|
||||
const edges = {};
|
||||
calibrationTestState[deviceId].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
|
||||
const edges: any = {};
|
||||
calibrationTestState[deviceId].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; });
|
||||
|
||||
updateCalibrationPreview();
|
||||
|
||||
@@ -848,11 +848,11 @@ export async function toggleTestEdge(edge) {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle test edge:', err);
|
||||
error.textContent = err.message || t('calibration.error.test_toggle_failed');
|
||||
@@ -860,7 +860,7 @@ export async function toggleTestEdge(edge) {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTestMode(deviceId) {
|
||||
async function clearTestMode(deviceId: any) {
|
||||
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) return;
|
||||
calibrationTestState[deviceId] = new Set();
|
||||
try {
|
||||
@@ -876,9 +876,9 @@ async function clearTestMode(deviceId) {
|
||||
|
||||
export async function saveCalibration() {
|
||||
const cssMode = _isCSS();
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const cssId = document.getElementById('calibration-css-id').value;
|
||||
const error = document.getElementById('calibration-error');
|
||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
|
||||
const error = document.getElementById('calibration-error') as HTMLElement;
|
||||
|
||||
if (cssMode) {
|
||||
await _clearCSSTestMode();
|
||||
@@ -887,15 +887,15 @@ export async function saveCalibration() {
|
||||
}
|
||||
updateCalibrationPreview();
|
||||
|
||||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
||||
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
|
||||
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
|
||||
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const topLeds = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0');
|
||||
const rightLeds = parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0');
|
||||
const bottomLeds = parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0');
|
||||
const leftLeds = parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0');
|
||||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||||
|
||||
const declaredLedCount = cssMode
|
||||
? parseInt(document.getElementById('cal-css-led-count').value) || 0
|
||||
: parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0;
|
||||
? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value) || 0
|
||||
: parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent!) || 0;
|
||||
if (!cssMode) {
|
||||
if (total !== declaredLedCount) {
|
||||
error.textContent = t('calibration.error.led_count_mismatch');
|
||||
@@ -910,9 +910,9 @@ export async function saveCalibration() {
|
||||
}
|
||||
}
|
||||
|
||||
const startPosition = document.getElementById('cal-start-position').value;
|
||||
const layout = document.getElementById('cal-layout').value;
|
||||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||||
const startPosition = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
|
||||
const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value;
|
||||
const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0');
|
||||
|
||||
const spans = window.edgeSpans || {};
|
||||
const calibration = {
|
||||
@@ -923,9 +923,9 @@ export async function saveCalibration() {
|
||||
span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1,
|
||||
span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1,
|
||||
span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1,
|
||||
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
|
||||
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
||||
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
|
||||
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
||||
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
||||
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -953,11 +953,11 @@ export async function saveCalibration() {
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.save_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to save calibration:', err);
|
||||
error.textContent = err.message || t('calibration.error.save_failed');
|
||||
@@ -965,8 +965,8 @@ export async function saveCalibration() {
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeOrder(startPosition, layout) {
|
||||
const orders = {
|
||||
function getEdgeOrder(startPosition: any, layout: any) {
|
||||
const orders: any = {
|
||||
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
|
||||
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||||
@@ -979,8 +979,8 @@ function getEdgeOrder(startPosition, layout) {
|
||||
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
|
||||
}
|
||||
|
||||
function shouldReverse(edge, startPosition, layout) {
|
||||
const reverseRules = {
|
||||
function shouldReverse(edge: any, startPosition: any, layout: any) {
|
||||
const reverseRules: any = {
|
||||
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
|
||||
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
||||
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
|
||||
@@ -994,19 +994,19 @@ function shouldReverse(edge, startPosition, layout) {
|
||||
return rules ? rules[edge] : false;
|
||||
}
|
||||
|
||||
function buildSegments(calibration) {
|
||||
function buildSegments(calibration: any) {
|
||||
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
|
||||
const edgeCounts = {
|
||||
const edgeCounts: any = {
|
||||
top: calibration.leds_top || 0,
|
||||
right: calibration.leds_right || 0,
|
||||
bottom: calibration.leds_bottom || 0,
|
||||
left: calibration.leds_left || 0
|
||||
};
|
||||
|
||||
const segments = [];
|
||||
const segments: any[] = [];
|
||||
let ledStart = calibration.offset || 0;
|
||||
|
||||
edgeOrder.forEach(edge => {
|
||||
edgeOrder.forEach((edge: any) => {
|
||||
const count = edgeCounts[edge];
|
||||
if (count > 0) {
|
||||
segments.push({
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,17 +5,41 @@
|
||||
* gradient stops state and renders into the CSS editor modal DOM.
|
||||
*/
|
||||
|
||||
import { t } from '../core/i18n.js';
|
||||
import { t } from '../core/i18n.ts';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface GradientStop {
|
||||
position: number;
|
||||
color: number[];
|
||||
colorRight: number[] | null;
|
||||
}
|
||||
|
||||
interface GradientDragState {
|
||||
idx: number;
|
||||
trackRect: DOMRect;
|
||||
}
|
||||
|
||||
interface GradientPresetStop {
|
||||
position: number;
|
||||
color: number[];
|
||||
color_right?: number[];
|
||||
}
|
||||
|
||||
interface CustomPreset {
|
||||
name: string;
|
||||
stops: GradientPresetStop[];
|
||||
}
|
||||
|
||||
/* ── Color conversion utilities ───────────────────────────────── */
|
||||
|
||||
export function rgbArrayToHex(rgb) {
|
||||
export function rgbArrayToHex(rgb: number[]): string {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||||
export function hexToRgbArray(hex) {
|
||||
export function hexToRgbArray(hex: string): number[] {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
@@ -26,22 +50,22 @@ export function hexToRgbArray(hex) {
|
||||
* Internal state: array of stop objects.
|
||||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||
*/
|
||||
let _gradientStops = [];
|
||||
let _gradientSelectedIdx = -1;
|
||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||
let _gradientOnChange = null;
|
||||
let _gradientStops: GradientStop[] = [];
|
||||
let _gradientSelectedIdx: number = -1;
|
||||
let _gradientDragging: GradientDragState | null = null;
|
||||
let _gradientOnChange: (() => void) | null = null;
|
||||
|
||||
/** Set a callback that fires whenever stops change. */
|
||||
export function gradientSetOnChange(fn) { _gradientOnChange = fn; }
|
||||
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
|
||||
|
||||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||||
export function getGradientStops() {
|
||||
export function getGradientStops(): GradientStop[] {
|
||||
return _gradientStops;
|
||||
}
|
||||
|
||||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||
|
||||
function _gradientInterpolate(stops, pos) {
|
||||
function _gradientInterpolate(stops: GradientStop[], pos: number): number[] {
|
||||
if (!stops.length) return [128, 128, 128];
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
@@ -66,9 +90,9 @@ function _gradientInterpolate(stops, pos) {
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientInit(stops) {
|
||||
export function gradientInit(stops: GradientPresetStop[]): void {
|
||||
_gradientStops = stops.map(s => ({
|
||||
position: parseFloat(s.position ?? 0),
|
||||
position: parseFloat(String(s.position ?? 0)),
|
||||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||
}));
|
||||
@@ -171,27 +195,27 @@ export const GRADIENT_PRESETS = {
|
||||
/**
|
||||
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||||
*/
|
||||
export function gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
export function gradientPresetStripHTML(stops: GradientPresetStop[], w: number = 80, h: number = 16): string {
|
||||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
}
|
||||
|
||||
export function applyGradientPreset(key) {
|
||||
export function applyGradientPreset(key: string): void {
|
||||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||||
gradientInit(GRADIENT_PRESETS[key]);
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientRenderAll() {
|
||||
export function gradientRenderAll(): void {
|
||||
_gradientRenderCanvas();
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
if (_gradientOnChange) _gradientOnChange();
|
||||
}
|
||||
|
||||
function _gradientRenderCanvas() {
|
||||
const canvas = document.getElementById('gradient-canvas');
|
||||
function _gradientRenderCanvas(): void {
|
||||
const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null;
|
||||
if (!canvas) return;
|
||||
|
||||
// Sync canvas pixel width to its CSS display width
|
||||
@@ -216,7 +240,7 @@ function _gradientRenderCanvas() {
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
function _gradientRenderMarkers() {
|
||||
function _gradientRenderMarkers(): void {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
@@ -245,13 +269,13 @@ function _gradientRenderMarkers() {
|
||||
* Update the selected stop index and reflect it via CSS classes only —
|
||||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||
*/
|
||||
function _gradientSelectStop(idx) {
|
||||
function _gradientSelectStop(idx: number): void {
|
||||
_gradientSelectedIdx = idx;
|
||||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||
}
|
||||
|
||||
function _gradientRenderStopList() {
|
||||
function _gradientRenderStopList(): void {
|
||||
const list = document.getElementById('gradient-stops-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
@@ -283,8 +307,9 @@ function _gradientRenderStopList() {
|
||||
// Position
|
||||
const posInput = row.querySelector('.gradient-stop-pos');
|
||||
posInput.addEventListener('change', (e) => {
|
||||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
e.target.value = val.toFixed(2);
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
|
||||
target.value = val.toFixed(2);
|
||||
_gradientStops[idx].position = val;
|
||||
gradientRenderAll();
|
||||
});
|
||||
@@ -292,9 +317,10 @@ function _gradientRenderStopList() {
|
||||
|
||||
// Left color
|
||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
_gradientStops[idx].color = hexToRgbArray(val);
|
||||
const markers = document.querySelectorAll('.gradient-marker');
|
||||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||
if (markers[idx]) (markers[idx] as HTMLElement).style.background = val;
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
@@ -310,7 +336,7 @@ function _gradientRenderStopList() {
|
||||
|
||||
// Right color
|
||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||
_gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value);
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
@@ -332,7 +358,7 @@ function _gradientRenderStopList() {
|
||||
|
||||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientAddStop(position) {
|
||||
export function gradientAddStop(position?: number): void {
|
||||
if (position === undefined) {
|
||||
// Find the largest gap between adjacent stops and place in the middle
|
||||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||
@@ -355,12 +381,12 @@ export function gradientAddStop(position) {
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||
|
||||
function _gradientStartDrag(e, idx) {
|
||||
function _gradientStartDrag(e: MouseEvent, idx: number): void {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||
|
||||
const onMove = (me) => {
|
||||
const onMove = (me: MouseEvent): void => {
|
||||
if (!_gradientDragging) return;
|
||||
const { trackRect } = _gradientDragging;
|
||||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||
@@ -368,7 +394,7 @@ function _gradientStartDrag(e, idx) {
|
||||
gradientRenderAll();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
const onUp = (): void => {
|
||||
_gradientDragging = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
@@ -383,7 +409,7 @@ function _gradientStartDrag(e, idx) {
|
||||
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
|
||||
|
||||
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
|
||||
export function loadCustomGradientPresets() {
|
||||
export function loadCustomGradientPresets(): CustomPreset[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
|
||||
} catch {
|
||||
@@ -392,7 +418,7 @@ export function loadCustomGradientPresets() {
|
||||
}
|
||||
|
||||
/** Save the current gradient stops as a named custom preset. */
|
||||
export function saveCurrentAsCustomPreset(name) {
|
||||
export function saveCurrentAsCustomPreset(name: string): void {
|
||||
if (!name) return;
|
||||
const stops = _gradientStops.map(s => ({
|
||||
position: s.position,
|
||||
@@ -408,17 +434,17 @@ export function saveCurrentAsCustomPreset(name) {
|
||||
}
|
||||
|
||||
/** Delete a custom preset by name. */
|
||||
export function deleteCustomGradientPreset(name) {
|
||||
export function deleteCustomGradientPreset(name: string): void {
|
||||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
function _gradientSetupTrackClick(): void {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track || track._gradientClickBound) return;
|
||||
track._gradientClickBound = true;
|
||||
if (!track || (track as any)._gradientClickBound) return;
|
||||
(track as any)._gradientClickBound = true;
|
||||
|
||||
track.addEventListener('click', (e) => {
|
||||
if (_gradientDragging) return;
|
||||
@@ -2,34 +2,38 @@
|
||||
* Dashboard — real-time target status overview.
|
||||
*/
|
||||
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.js';
|
||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.js';
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
|
||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.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,
|
||||
} from '../core/icons.js';
|
||||
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
|
||||
import { cardColorStyle } from '../core/card-colors.js';
|
||||
import { createFpsSparkline } from '../core/chart-utils.js';
|
||||
} from '../core/icons.ts';
|
||||
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.ts';
|
||||
import { cardColorStyle } from '../core/card-colors.ts';
|
||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts';
|
||||
|
||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||
const MAX_FPS_SAMPLES = 120;
|
||||
|
||||
let _fpsHistory = {}; // { targetId: number[] } — fps_actual
|
||||
let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current
|
||||
let _fpsCharts = {}; // { targetId: Chart }
|
||||
let _lastRunningIds = []; // sorted target IDs from previous render
|
||||
let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs
|
||||
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
|
||||
let _uptimeTimer = null;
|
||||
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
|
||||
let _metricsElements = new Map();
|
||||
interface UptimeBase { seconds: number; timestamp: number; }
|
||||
interface MetricsRefs { fps: Element | null; errors: Element | null; row: Element | null; }
|
||||
|
||||
function _pushFps(targetId, actual, current) {
|
||||
let _fpsHistory: Record<string, number[]> = {};
|
||||
let _fpsCurrentHistory: Record<string, number[]> = {};
|
||||
let _fpsCharts: Record<string, any> = {};
|
||||
let _lastRunningIds: string[] = [];
|
||||
let _lastSyncClockIds: string = '';
|
||||
let _uptimeBase: Record<string, UptimeBase> = {};
|
||||
let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _uptimeElements: Record<string, Element> = {};
|
||||
let _metricsElements: Map<string, MetricsRefs> = new Map();
|
||||
|
||||
function _pushFps(targetId: string, actual: number, current: number): void {
|
||||
if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
|
||||
_fpsHistory[targetId].push(actual);
|
||||
if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift();
|
||||
@@ -39,18 +43,18 @@ function _pushFps(targetId, actual, current) {
|
||||
if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift();
|
||||
}
|
||||
|
||||
function _setUptimeBase(targetId, seconds) {
|
||||
function _setUptimeBase(targetId: string, seconds: number): void {
|
||||
_uptimeBase[targetId] = { seconds, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function _getInterpolatedUptime(targetId) {
|
||||
function _getInterpolatedUptime(targetId: string): number | null {
|
||||
const base = _uptimeBase[targetId];
|
||||
if (!base) return null;
|
||||
const elapsed = (Date.now() - base.timestamp) / 1000;
|
||||
return base.seconds + elapsed;
|
||||
}
|
||||
|
||||
function _cacheUptimeElements() {
|
||||
function _cacheUptimeElements(): void {
|
||||
_uptimeElements = {};
|
||||
for (const id of _lastRunningIds) {
|
||||
const el = document.querySelector(`[data-uptime-text="${id}"]`);
|
||||
@@ -58,7 +62,7 @@ function _cacheUptimeElements() {
|
||||
}
|
||||
}
|
||||
|
||||
function _startUptimeTimer() {
|
||||
function _startUptimeTimer(): void {
|
||||
if (_uptimeTimer) return;
|
||||
_uptimeTimer = setInterval(() => {
|
||||
for (const id of _lastRunningIds) {
|
||||
@@ -72,7 +76,7 @@ function _startUptimeTimer() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function _stopUptimeTimer() {
|
||||
function _stopUptimeTimer(): void {
|
||||
if (_uptimeTimer) {
|
||||
clearInterval(_uptimeTimer);
|
||||
_uptimeTimer = null;
|
||||
@@ -81,18 +85,18 @@ function _stopUptimeTimer() {
|
||||
_uptimeElements = {};
|
||||
}
|
||||
|
||||
function _destroyFpsCharts() {
|
||||
function _destroyFpsCharts(): void {
|
||||
for (const id of Object.keys(_fpsCharts)) {
|
||||
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); }
|
||||
}
|
||||
_fpsCharts = {};
|
||||
}
|
||||
|
||||
function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
|
||||
function _createFpsChart(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number): any {
|
||||
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget);
|
||||
}
|
||||
|
||||
async function _initFpsCharts(runningTargetIds) {
|
||||
async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
|
||||
_destroyFpsCharts();
|
||||
|
||||
// Seed FPS history from server ring buffer on first load
|
||||
@@ -129,7 +133,7 @@ async function _initFpsCharts(runningTargetIds) {
|
||||
_cacheMetricsElements(runningTargetIds);
|
||||
}
|
||||
|
||||
function _cacheMetricsElements(runningIds) {
|
||||
function _cacheMetricsElements(runningIds: string[]): void {
|
||||
_metricsElements.clear();
|
||||
for (const id of runningIds) {
|
||||
_metricsElements.set(id, {
|
||||
@@ -141,7 +145,7 @@ function _cacheMetricsElements(runningIds) {
|
||||
}
|
||||
|
||||
/** Update running target metrics in-place (no HTML rebuild). */
|
||||
function _updateRunningMetrics(enrichedRunning) {
|
||||
function _updateRunningMetrics(enrichedRunning: any[]): void {
|
||||
for (const target of enrichedRunning) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
@@ -189,7 +193,7 @@ function _updateRunningMetrics(enrichedRunning) {
|
||||
}
|
||||
|
||||
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
|
||||
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; errorsEl.title = String(errors); }
|
||||
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
|
||||
|
||||
// Update health dot — prefer streaming reachability when processing
|
||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||
@@ -211,7 +215,7 @@ function _updateRunningMetrics(enrichedRunning) {
|
||||
|
||||
}
|
||||
|
||||
function _updateAutomationsInPlace(automations) {
|
||||
function _updateAutomationsInPlace(automations: Automation[]): void {
|
||||
for (const a of automations) {
|
||||
const card = document.querySelector(`[data-automation-id="${a.id}"]`);
|
||||
if (!card) continue;
|
||||
@@ -237,7 +241,7 @@ function _updateAutomationsInPlace(automations) {
|
||||
}
|
||||
}
|
||||
|
||||
function _updateSyncClocksInPlace(syncClocks) {
|
||||
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
|
||||
for (const c of syncClocks) {
|
||||
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
|
||||
if (!card) continue;
|
||||
@@ -252,7 +256,7 @@ function _updateSyncClocksInPlace(syncClocks) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardSyncClock(clock) {
|
||||
function renderDashboardSyncClock(clock: SyncClock): string {
|
||||
const toggleAction = clock.is_running
|
||||
? `dashboardPauseClock('${clock.id}')`
|
||||
: `dashboardResumeClock('${clock.id}')`;
|
||||
@@ -283,18 +287,18 @@ function renderDashboardSyncClock(clock) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderPollIntervalSelect() {
|
||||
function _renderPollIntervalSelect(): string {
|
||||
const sec = Math.round(dashboardPollInterval / 1000);
|
||||
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
|
||||
}
|
||||
|
||||
let _pollDebounce = null;
|
||||
export function changeDashboardPollInterval(value) {
|
||||
let _pollDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
export function changeDashboardPollInterval(value: string | number): void {
|
||||
const label = document.querySelector('.dashboard-poll-value');
|
||||
if (label) label.textContent = `${value}s`;
|
||||
clearTimeout(_pollDebounce);
|
||||
_pollDebounce = setTimeout(() => {
|
||||
const ms = parseInt(value, 10) * 1000;
|
||||
const ms = parseInt(String(value), 10) * 1000;
|
||||
setDashboardPollInterval(ms);
|
||||
startAutoRefresh();
|
||||
stopPerfPolling();
|
||||
@@ -302,12 +306,12 @@ export function changeDashboardPollInterval(value) {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function _getCollapsedSections() {
|
||||
function _getCollapsedSections(): Record<string, boolean> {
|
||||
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export function toggleDashboardSection(sectionKey) {
|
||||
export function toggleDashboardSection(sectionKey: string): void {
|
||||
const collapsed = _getCollapsedSections();
|
||||
collapsed[sectionKey] = !collapsed[sectionKey];
|
||||
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
|
||||
@@ -316,37 +320,38 @@ export function toggleDashboardSection(sectionKey) {
|
||||
const content = header.nextElementSibling;
|
||||
const chevron = header.querySelector('.dashboard-section-chevron');
|
||||
const nowCollapsed = collapsed[sectionKey];
|
||||
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
||||
if (chevron) (chevron as HTMLElement).style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
||||
|
||||
// Animate collapse/expand unless reduced motion
|
||||
const contentEl = content as any;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
content.style.display = nowCollapsed ? 'none' : '';
|
||||
contentEl.style.display = nowCollapsed ? 'none' : '';
|
||||
return;
|
||||
}
|
||||
if (content._dsAnim) { content._dsAnim.cancel(); content._dsAnim = null; }
|
||||
if (contentEl._dsAnim) { contentEl._dsAnim.cancel(); contentEl._dsAnim = null; }
|
||||
if (nowCollapsed) {
|
||||
const h = content.offsetHeight;
|
||||
content.style.overflow = 'hidden';
|
||||
const anim = content.animate(
|
||||
const h = contentEl.offsetHeight;
|
||||
contentEl.style.overflow = 'hidden';
|
||||
const anim = contentEl.animate(
|
||||
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: 200, easing: 'ease-in-out' }
|
||||
);
|
||||
content._dsAnim = anim;
|
||||
anim.onfinish = () => { content.style.display = 'none'; content.style.overflow = ''; content._dsAnim = null; };
|
||||
contentEl._dsAnim = anim;
|
||||
anim.onfinish = () => { contentEl.style.display = 'none'; contentEl.style.overflow = ''; contentEl._dsAnim = null; };
|
||||
} else {
|
||||
content.style.display = '';
|
||||
content.style.overflow = 'hidden';
|
||||
const h = content.scrollHeight;
|
||||
const anim = content.animate(
|
||||
contentEl.style.display = '';
|
||||
contentEl.style.overflow = 'hidden';
|
||||
const h = contentEl.scrollHeight;
|
||||
const anim = contentEl.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
|
||||
{ duration: 200, easing: 'ease-in-out' }
|
||||
);
|
||||
content._dsAnim = anim;
|
||||
anim.onfinish = () => { content.style.overflow = ''; content._dsAnim = null; };
|
||||
contentEl._dsAnim = anim;
|
||||
anim.onfinish = () => { contentEl.style.overflow = ''; contentEl._dsAnim = null; };
|
||||
}
|
||||
}
|
||||
|
||||
function _sectionHeader(sectionKey, label, count, extraHtml = '') {
|
||||
function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = ''): string {
|
||||
const collapsed = _getCollapsedSections();
|
||||
const isCollapsed = !!collapsed[sectionKey];
|
||||
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
||||
@@ -358,13 +363,13 @@ function _sectionHeader(sectionKey, label, count, extraHtml = '') {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _sectionContent(sectionKey, itemsHtml) {
|
||||
function _sectionContent(sectionKey: string, itemsHtml: string): string {
|
||||
const collapsed = _getCollapsedSections();
|
||||
const isCollapsed = !!collapsed[sectionKey];
|
||||
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
|
||||
}
|
||||
|
||||
export async function loadDashboard(forceFullRender = false) {
|
||||
export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
|
||||
if (_dashboardLoading) return;
|
||||
set_dashboardLoading(true);
|
||||
const container = document.getElementById('dashboard-content');
|
||||
@@ -454,7 +459,7 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
// Scene Presets section
|
||||
if (scenePresets.length > 0) {
|
||||
const sceneSec = renderScenePresetsSection(scenePresets);
|
||||
if (sceneSec) {
|
||||
if (sceneSec && typeof sceneSec === 'object') {
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
|
||||
${_sectionContent('scenes', sceneSec.content)}
|
||||
@@ -536,7 +541,7 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap = {}) {
|
||||
function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Record<string, Device> = {}, cssSourceMap: Record<string, ColorStripSource> = {}): string {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||
@@ -632,7 +637,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardAutomation(automation, sceneMap = new Map()) {
|
||||
function renderDashboardAutomation(automation: Automation, sceneMap: Map<string, ScenePreset> = new Map()): string {
|
||||
const isActive = automation.is_active;
|
||||
const isDisabled = !automation.enabled;
|
||||
|
||||
@@ -681,7 +686,7 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function dashboardToggleAutomation(automationId, enable) {
|
||||
export async function dashboardToggleAutomation(automationId: string, enable: boolean): Promise<void> {
|
||||
try {
|
||||
const endpoint = enable ? 'enable' : 'disable';
|
||||
const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, {
|
||||
@@ -696,7 +701,7 @@ export async function dashboardToggleAutomation(automationId, enable) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStartTarget(targetId) {
|
||||
export async function dashboardStartTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
|
||||
method: 'POST',
|
||||
@@ -714,7 +719,7 @@ export async function dashboardStartTarget(targetId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStopTarget(targetId) {
|
||||
export async function dashboardStopTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
|
||||
method: 'POST',
|
||||
@@ -732,7 +737,7 @@ export async function dashboardStopTarget(targetId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
export async function dashboardStopAll(): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
@@ -753,7 +758,7 @@ export async function dashboardStopAll() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardPauseClock(clockId) {
|
||||
export async function dashboardPauseClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -765,7 +770,7 @@ export async function dashboardPauseClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardResumeClock(clockId) {
|
||||
export async function dashboardResumeClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -777,7 +782,7 @@ export async function dashboardResumeClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardResetClock(clockId) {
|
||||
export async function dashboardResetClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -789,17 +794,17 @@ export async function dashboardResetClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function stopUptimeTimer() {
|
||||
export function stopUptimeTimer(): void {
|
||||
_stopUptimeTimer();
|
||||
}
|
||||
|
||||
// React to global server events when dashboard tab is active
|
||||
function _isDashboardActive() {
|
||||
function _isDashboardActive(): boolean {
|
||||
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
|
||||
}
|
||||
|
||||
let _eventDebounceTimer = null;
|
||||
function _debouncedDashboardReload(forceFullRender = false) {
|
||||
let _eventDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
|
||||
if (!_isDashboardActive()) return;
|
||||
clearTimeout(_eventDebounceTimer);
|
||||
_eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300);
|
||||
@@ -810,8 +815,8 @@ document.addEventListener('server:automation_state_changed', () => _debouncedDas
|
||||
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
|
||||
|
||||
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
|
||||
document.addEventListener('server:entity_changed', (e) => {
|
||||
const { entity_type } = e.detail || {};
|
||||
document.addEventListener('server:entity_changed', (e: Event) => {
|
||||
const { entity_type } = (e as CustomEvent).detail || {};
|
||||
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
|
||||
});
|
||||
|
||||
@@ -6,36 +6,36 @@ import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { _computeMaxFps, _renderFpsHint } from './devices.js';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { _computeMaxFps, _renderFpsHint } from './devices.ts';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
|
||||
class AddDeviceModal extends Modal {
|
||||
constructor() { super('add-device-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('device-name').value,
|
||||
type: document.getElementById('device-type').value,
|
||||
url: document.getElementById('device-url').value,
|
||||
serialPort: document.getElementById('device-serial-port').value,
|
||||
ledCount: document.getElementById('device-led-count').value,
|
||||
baudRate: document.getElementById('device-baud-rate').value,
|
||||
ledType: document.getElementById('device-led-type')?.value || 'rgb',
|
||||
sendLatency: document.getElementById('device-send-latency')?.value || '0',
|
||||
name: (document.getElementById('device-name') as HTMLInputElement).value,
|
||||
type: (document.getElementById('device-type') as HTMLSelectElement).value,
|
||||
url: (document.getElementById('device-url') as HTMLInputElement).value,
|
||||
serialPort: (document.getElementById('device-serial-port') as HTMLSelectElement).value,
|
||||
ledCount: (document.getElementById('device-led-count') as HTMLInputElement).value,
|
||||
baudRate: (document.getElementById('device-baud-rate') as HTMLSelectElement).value,
|
||||
ledType: (document.getElementById('device-led-type') as HTMLSelectElement)?.value || 'rgb',
|
||||
sendLatency: (document.getElementById('device-send-latency') as HTMLInputElement)?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('device-zone-list')),
|
||||
zoneMode: _getZoneMode(),
|
||||
csptId: document.getElementById('device-css-processing-template')?.value || '',
|
||||
dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1',
|
||||
csptId: (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value || '',
|
||||
dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
|
||||
dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
|
||||
dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,14 +55,14 @@ function _buildDeviceTypeItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
let _deviceTypeIconSelect = null;
|
||||
let _csptEntitySelect = null;
|
||||
let _deviceTypeIconSelect: any = null;
|
||||
let _csptEntitySelect: any = null;
|
||||
|
||||
function _ensureDeviceTypeIconSelect() {
|
||||
const sel = document.getElementById('device-type');
|
||||
if (!sel) return;
|
||||
if (_deviceTypeIconSelect) { _deviceTypeIconSelect.updateItems(_buildDeviceTypeItems()); return; }
|
||||
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 });
|
||||
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 } as any);
|
||||
}
|
||||
|
||||
function _ensureCsptEntitySelect() {
|
||||
@@ -71,12 +71,12 @@ function _ensureCsptEntitySelect() {
|
||||
const templates = csptCache.data || [];
|
||||
// Populate native <select> options
|
||||
sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
|
||||
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
if (_csptEntitySelect) _csptEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_csptEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (csptCache.data || []).map(tp => ({
|
||||
getItems: () => (csptCache.data || []).map((tp: any) => ({
|
||||
value: tp.id,
|
||||
label: tp.name,
|
||||
icon: ICON_TEMPLATE,
|
||||
@@ -85,7 +85,7 @@ function _ensureCsptEntitySelect() {
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: t('common.none_no_cspt'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ function _buildDmxProtocolItems() {
|
||||
];
|
||||
}
|
||||
|
||||
const _dmxProtocolIconSelects = {};
|
||||
const _dmxProtocolIconSelects: Record<string, any> = {};
|
||||
|
||||
export function ensureDmxProtocolIconSelect(selectId) {
|
||||
export function ensureDmxProtocolIconSelect(selectId: any) {
|
||||
const sel = document.getElementById(selectId);
|
||||
if (!sel) return;
|
||||
if (_dmxProtocolIconSelects[selectId]) {
|
||||
@@ -111,10 +111,10 @@ export function ensureDmxProtocolIconSelect(selectId) {
|
||||
target: sel,
|
||||
items: _buildDmxProtocolItems(),
|
||||
columns: 2,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
|
||||
export function destroyDmxProtocolIconSelect(selectId) {
|
||||
export function destroyDmxProtocolIconSelect(selectId: any) {
|
||||
if (_dmxProtocolIconSelects[selectId]) {
|
||||
_dmxProtocolIconSelects[selectId].destroy();
|
||||
delete _dmxProtocolIconSelects[selectId];
|
||||
@@ -133,9 +133,9 @@ function _buildSpiLedTypeItems() {
|
||||
];
|
||||
}
|
||||
|
||||
const _spiLedTypeIconSelects = {};
|
||||
const _spiLedTypeIconSelects: Record<string, any> = {};
|
||||
|
||||
export function ensureSpiLedTypeIconSelect(selectId) {
|
||||
export function ensureSpiLedTypeIconSelect(selectId: any) {
|
||||
const sel = document.getElementById(selectId);
|
||||
if (!sel) return;
|
||||
if (_spiLedTypeIconSelects[selectId]) {
|
||||
@@ -146,10 +146,10 @@ export function ensureSpiLedTypeIconSelect(selectId) {
|
||||
target: sel,
|
||||
items: _buildSpiLedTypeItems(),
|
||||
columns: 3,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
|
||||
export function destroySpiLedTypeIconSelect(selectId) {
|
||||
export function destroySpiLedTypeIconSelect(selectId: any) {
|
||||
if (_spiLedTypeIconSelects[selectId]) {
|
||||
_spiLedTypeIconSelects[selectId].destroy();
|
||||
delete _spiLedTypeIconSelects[selectId];
|
||||
@@ -168,9 +168,9 @@ function _buildGameSenseDeviceTypeItems() {
|
||||
];
|
||||
}
|
||||
|
||||
const _gameSenseDeviceTypeIconSelects = {};
|
||||
const _gameSenseDeviceTypeIconSelects: Record<string, any> = {};
|
||||
|
||||
export function ensureGameSenseDeviceTypeIconSelect(selectId) {
|
||||
export function ensureGameSenseDeviceTypeIconSelect(selectId: any) {
|
||||
const sel = document.getElementById(selectId);
|
||||
if (!sel) return;
|
||||
if (_gameSenseDeviceTypeIconSelects[selectId]) {
|
||||
@@ -181,10 +181,10 @@ export function ensureGameSenseDeviceTypeIconSelect(selectId) {
|
||||
target: sel,
|
||||
items: _buildGameSenseDeviceTypeItems(),
|
||||
columns: 3,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
|
||||
export function destroyGameSenseDeviceTypeIconSelect(selectId) {
|
||||
export function destroyGameSenseDeviceTypeIconSelect(selectId: any) {
|
||||
if (_gameSenseDeviceTypeIconSelects[selectId]) {
|
||||
_gameSenseDeviceTypeIconSelects[selectId].destroy();
|
||||
delete _gameSenseDeviceTypeIconSelects[selectId];
|
||||
@@ -192,31 +192,31 @@ export function destroyGameSenseDeviceTypeIconSelect(selectId) {
|
||||
}
|
||||
|
||||
export function onDeviceTypeChanged() {
|
||||
const deviceType = document.getElementById('device-type').value;
|
||||
const deviceType = (document.getElementById('device-type') as HTMLSelectElement).value;
|
||||
if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType);
|
||||
const urlGroup = document.getElementById('device-url-group');
|
||||
const urlInput = document.getElementById('device-url');
|
||||
const serialGroup = document.getElementById('device-serial-port-group');
|
||||
const serialSelect = document.getElementById('device-serial-port');
|
||||
const ledCountGroup = document.getElementById('device-led-count-group');
|
||||
const discoverySection = document.getElementById('discovery-section');
|
||||
const baudRateGroup = document.getElementById('device-baud-rate-group');
|
||||
const ledTypeGroup = document.getElementById('device-led-type-group');
|
||||
const sendLatencyGroup = document.getElementById('device-send-latency-group');
|
||||
const urlGroup = document.getElementById('device-url-group') as HTMLElement;
|
||||
const urlInput = document.getElementById('device-url') as HTMLInputElement;
|
||||
const serialGroup = document.getElementById('device-serial-port-group') as HTMLElement;
|
||||
const serialSelect = document.getElementById('device-serial-port') as HTMLSelectElement;
|
||||
const ledCountGroup = document.getElementById('device-led-count-group') as HTMLElement;
|
||||
const discoverySection = document.getElementById('discovery-section') as HTMLElement;
|
||||
const baudRateGroup = document.getElementById('device-baud-rate-group') as HTMLElement;
|
||||
const ledTypeGroup = document.getElementById('device-led-type-group') as HTMLElement;
|
||||
const sendLatencyGroup = document.getElementById('device-send-latency-group') as HTMLElement;
|
||||
|
||||
// URL label / hint / placeholder — adapt per device type
|
||||
const urlLabel = document.getElementById('device-url-label');
|
||||
const urlHint = document.getElementById('device-url-hint');
|
||||
const urlLabel = document.getElementById('device-url-label') as HTMLElement;
|
||||
const urlHint = document.getElementById('device-url-hint') as HTMLElement;
|
||||
|
||||
const zoneGroup = document.getElementById('device-zone-group');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group');
|
||||
const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group');
|
||||
const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group');
|
||||
const zoneGroup = document.getElementById('device-zone-group') as HTMLElement;
|
||||
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
||||
const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group') as HTMLElement;
|
||||
const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group') as HTMLElement;
|
||||
const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group') as HTMLElement;
|
||||
|
||||
// Hide zone group + mode group by default (shown only for openrgb)
|
||||
if (zoneGroup) zoneGroup.style.display = 'none';
|
||||
const zoneModeGroup = document.getElementById('device-zone-mode-group');
|
||||
const zoneModeGroup = document.getElementById('device-zone-mode-group') as HTMLElement;
|
||||
if (zoneModeGroup) zoneModeGroup.style.display = 'none';
|
||||
|
||||
// Hide DMX fields by default
|
||||
@@ -482,15 +482,15 @@ export function onDeviceTypeChanged() {
|
||||
}
|
||||
|
||||
export function updateBaudFpsHint() {
|
||||
const hintEl = document.getElementById('baud-fps-hint');
|
||||
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
|
||||
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
|
||||
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
||||
const hintEl = document.getElementById('baud-fps-hint') as HTMLElement;
|
||||
const baudRate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement).value, 10);
|
||||
const ledCount = parseInt((document.getElementById('device-led-count') as HTMLInputElement).value, 10);
|
||||
const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'adalight';
|
||||
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
|
||||
}
|
||||
|
||||
function _renderDiscoveryList() {
|
||||
const selectedType = document.getElementById('device-type').value;
|
||||
const selectedType = (document.getElementById('device-type') as HTMLSelectElement).value;
|
||||
const devices = _discoveryCache[selectedType];
|
||||
|
||||
// Serial devices: populate serial port dropdown instead of discovery list
|
||||
@@ -500,9 +500,9 @@ function _renderDiscoveryList() {
|
||||
}
|
||||
|
||||
// WLED and others: render discovery list cards
|
||||
const list = document.getElementById('discovery-list');
|
||||
const empty = document.getElementById('discovery-empty');
|
||||
const section = document.getElementById('discovery-section');
|
||||
const list = document.getElementById('discovery-list') as HTMLElement;
|
||||
const empty = document.getElementById('discovery-empty') as HTMLElement;
|
||||
const section = document.getElementById('discovery-section') as HTMLElement;
|
||||
if (!list || !section) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
@@ -520,7 +520,7 @@ function _renderDiscoveryList() {
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
devices.forEach(device => {
|
||||
devices.forEach((device: any) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
||||
const meta = [device.ip];
|
||||
@@ -542,8 +542,8 @@ function _renderDiscoveryList() {
|
||||
});
|
||||
}
|
||||
|
||||
function _populateSerialPortDropdown(devices) {
|
||||
const select = document.getElementById('device-serial-port');
|
||||
function _populateSerialPortDropdown(devices: any) {
|
||||
const select = document.getElementById('device-serial-port') as HTMLSelectElement;
|
||||
select.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
@@ -563,7 +563,7 @@ function _populateSerialPortDropdown(devices) {
|
||||
placeholder.selected = true;
|
||||
select.appendChild(placeholder);
|
||||
|
||||
devices.forEach(device => {
|
||||
devices.forEach((device: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.url;
|
||||
opt.textContent = device.name;
|
||||
@@ -576,89 +576,89 @@ function _populateSerialPortDropdown(devices) {
|
||||
|
||||
export function onSerialPortFocus() {
|
||||
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
||||
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
||||
const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'adalight';
|
||||
if (!(deviceType in _discoveryCache)) {
|
||||
scanForDevices(deviceType);
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddDevice(presetType = null, cloneData = null) {
|
||||
export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
||||
// When no type specified: show type picker first
|
||||
if (!presetType) {
|
||||
showTypePicker({
|
||||
title: t('device.select_type'),
|
||||
items: _buildDeviceTypeItems(),
|
||||
onPick: (type) => showAddDevice(type),
|
||||
onPick: (type: any) => showAddDevice(type),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById('add-device-form');
|
||||
const error = document.getElementById('add-device-error');
|
||||
const form = document.getElementById('add-device-form') as HTMLFormElement;
|
||||
const error = document.getElementById('add-device-error') as HTMLElement;
|
||||
form.reset();
|
||||
error.style.display = 'none';
|
||||
set_discoveryCache({});
|
||||
// Reset discovery section
|
||||
const section = document.getElementById('discovery-section');
|
||||
const section = document.getElementById('discovery-section') as HTMLElement;
|
||||
if (section) {
|
||||
section.style.display = 'none';
|
||||
document.getElementById('discovery-list').innerHTML = '';
|
||||
document.getElementById('discovery-empty').style.display = 'none';
|
||||
document.getElementById('discovery-loading').style.display = 'none';
|
||||
(document.getElementById('discovery-list') as HTMLElement).innerHTML = '';
|
||||
(document.getElementById('discovery-empty') as HTMLElement).style.display = 'none';
|
||||
(document.getElementById('discovery-loading') as HTMLElement).style.display = 'none';
|
||||
}
|
||||
// Reset serial port dropdown
|
||||
document.getElementById('device-serial-port').innerHTML = '';
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
(document.getElementById('device-serial-port') as HTMLSelectElement).innerHTML = '';
|
||||
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
_ensureDeviceTypeIconSelect();
|
||||
// Populate CSPT template selector
|
||||
csptCache.fetch().then(() => _ensureCsptEntitySelect());
|
||||
|
||||
// Pre-select type and hide the type selector (already chosen)
|
||||
document.getElementById('device-type').value = presetType;
|
||||
document.getElementById('device-type-group').style.display = 'none';
|
||||
(document.getElementById('device-type') as HTMLSelectElement).value = presetType;
|
||||
(document.getElementById('device-type-group') as HTMLElement).style.display = 'none';
|
||||
const typeIcon = getDeviceTypeIcon(presetType);
|
||||
const typeName = t(`device.type.${presetType}`);
|
||||
document.getElementById('add-device-modal-title').innerHTML = `${typeIcon} ${t('devices.add')}: ${typeName}`;
|
||||
(document.getElementById('add-device-modal-title') as HTMLElement).innerHTML = `${typeIcon} ${t('devices.add')}: ${typeName}`;
|
||||
|
||||
addDeviceModal.open();
|
||||
onDeviceTypeChanged();
|
||||
|
||||
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
|
||||
if (cloneData) {
|
||||
document.getElementById('device-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
(document.getElementById('device-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
// Clear URL — devices must have unique addresses, user must enter a new one
|
||||
const urlInput = document.getElementById('device-url');
|
||||
const urlInput = document.getElementById('device-url') as HTMLInputElement;
|
||||
if (urlInput) urlInput.value = '';
|
||||
// Prefill LED count
|
||||
const ledCountInput = document.getElementById('device-led-count');
|
||||
const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
|
||||
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
|
||||
// Prefill baud rate for serial devices
|
||||
if (isSerialDevice(presetType)) {
|
||||
const baudSelect = document.getElementById('device-baud-rate');
|
||||
const baudSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
|
||||
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
|
||||
}
|
||||
// Prefill mock device fields
|
||||
if (isMockDevice(presetType)) {
|
||||
const ledTypeEl = document.getElementById('device-led-type');
|
||||
const ledTypeEl = document.getElementById('device-led-type') as HTMLSelectElement;
|
||||
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
|
||||
const sendLatencyEl = document.getElementById('device-send-latency');
|
||||
const sendLatencyEl = document.getElementById('device-send-latency') as HTMLInputElement;
|
||||
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
|
||||
}
|
||||
// Prefill DMX fields
|
||||
if (isDmxDevice(presetType)) {
|
||||
const dmxProto = document.getElementById('device-dmx-protocol');
|
||||
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
|
||||
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
|
||||
const dmxUniverse = document.getElementById('device-dmx-start-universe');
|
||||
const dmxUniverse = document.getElementById('device-dmx-start-universe') as HTMLInputElement;
|
||||
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
|
||||
const dmxChannel = document.getElementById('device-dmx-start-channel');
|
||||
const dmxChannel = document.getElementById('device-dmx-start-channel') as HTMLInputElement;
|
||||
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
|
||||
}
|
||||
// Prefill CSPT template selector (after fetch completes)
|
||||
if (cloneData.default_css_processing_template_id) {
|
||||
csptCache.fetch().then(() => {
|
||||
_ensureCsptEntitySelect();
|
||||
const csptEl = document.getElementById('device-css-processing-template');
|
||||
const csptEl = document.getElementById('device-css-processing-template') as HTMLSelectElement;
|
||||
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
|
||||
});
|
||||
}
|
||||
@@ -674,22 +674,22 @@ export async function closeAddDeviceModal() {
|
||||
await addDeviceModal.close();
|
||||
}
|
||||
|
||||
export async function scanForDevices(forceType) {
|
||||
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
|
||||
export async function scanForDevices(forceType?: any) {
|
||||
const scanType = forceType || (document.getElementById('device-type') as HTMLSelectElement)?.value || 'wled';
|
||||
|
||||
// Per-type guard: prevent duplicate scans for the same type
|
||||
if (_discoveryScanRunning === scanType) return;
|
||||
set_discoveryScanRunning(scanType);
|
||||
|
||||
const loading = document.getElementById('discovery-loading');
|
||||
const list = document.getElementById('discovery-list');
|
||||
const empty = document.getElementById('discovery-empty');
|
||||
const section = document.getElementById('discovery-section');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
const loading = document.getElementById('discovery-loading') as HTMLElement;
|
||||
const list = document.getElementById('discovery-list') as HTMLElement;
|
||||
const empty = document.getElementById('discovery-empty') as HTMLElement;
|
||||
const section = document.getElementById('discovery-section') as HTMLElement;
|
||||
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
||||
|
||||
if (isSerialDevice(scanType)) {
|
||||
// Show loading in the serial port dropdown
|
||||
const select = document.getElementById('device-serial-port');
|
||||
const select = document.getElementById('device-serial-port') as HTMLSelectElement;
|
||||
select.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
@@ -714,7 +714,7 @@ export async function scanForDevices(forceType) {
|
||||
if (!response.ok) {
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -723,17 +723,17 @@ export async function scanForDevices(forceType) {
|
||||
_discoveryCache[scanType] = data.devices || [];
|
||||
|
||||
// Only render if the user is still on this type
|
||||
const currentType = document.getElementById('device-type')?.value;
|
||||
const currentType = (document.getElementById('device-type') as HTMLSelectElement)?.value;
|
||||
if (currentType === scanType) {
|
||||
_renderDiscoveryList();
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
|
||||
}
|
||||
console.error('Device scan failed:', err);
|
||||
} finally {
|
||||
@@ -743,15 +743,15 @@ export async function scanForDevices(forceType) {
|
||||
}
|
||||
}
|
||||
|
||||
export function selectDiscoveredDevice(device) {
|
||||
document.getElementById('device-name').value = device.name;
|
||||
const typeSelect = document.getElementById('device-type');
|
||||
export function selectDiscoveredDevice(device: any) {
|
||||
(document.getElementById('device-name') as HTMLInputElement).value = device.name;
|
||||
const typeSelect = document.getElementById('device-type') as HTMLSelectElement;
|
||||
if (typeSelect) typeSelect.value = device.device_type;
|
||||
onDeviceTypeChanged();
|
||||
if (isSerialDevice(device.device_type)) {
|
||||
document.getElementById('device-serial-port').value = device.url;
|
||||
(document.getElementById('device-serial-port') as HTMLSelectElement).value = device.url;
|
||||
} else {
|
||||
document.getElementById('device-url').value = device.url;
|
||||
(document.getElementById('device-url') as HTMLInputElement).value = device.url;
|
||||
}
|
||||
// Fetch zones for OpenRGB devices
|
||||
if (isOpenrgbDevice(device.device_type)) {
|
||||
@@ -760,12 +760,12 @@ export function selectDiscoveredDevice(device) {
|
||||
showToast(t('device.scan.selected'), 'info');
|
||||
}
|
||||
|
||||
export async function handleAddDevice(event) {
|
||||
export async function handleAddDevice(event: any) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('device-name').value.trim();
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
const error = document.getElementById('add-device-error');
|
||||
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
|
||||
const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'wled';
|
||||
const error = document.getElementById('add-device-error') as HTMLElement;
|
||||
|
||||
let url;
|
||||
if (isMockDevice(deviceType)) {
|
||||
@@ -773,14 +773,14 @@ export async function handleAddDevice(event) {
|
||||
} else if (isWsDevice(deviceType)) {
|
||||
url = 'ws://';
|
||||
} else if (isSerialDevice(deviceType) || isEspnowDevice(deviceType)) {
|
||||
url = document.getElementById('device-serial-port').value;
|
||||
url = (document.getElementById('device-serial-port') as HTMLSelectElement).value;
|
||||
} else if (isChromaDevice(deviceType)) {
|
||||
const chromaType = document.getElementById('device-chroma-device-type')?.value || 'chromalink';
|
||||
const chromaType = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
|
||||
url = `chroma://${chromaType}`;
|
||||
} else if (isGameSenseDevice(deviceType)) {
|
||||
url = 'gamesense://auto';
|
||||
} else {
|
||||
url = document.getElementById('device-url').value.trim();
|
||||
url = (document.getElementById('device-url') as HTMLInputElement).value.trim();
|
||||
}
|
||||
|
||||
// MQTT: ensure mqtt:// prefix
|
||||
@@ -803,50 +803,50 @@ export async function handleAddDevice(event) {
|
||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||
|
||||
try {
|
||||
const body = { name, url, device_type: deviceType };
|
||||
const ledCountInput = document.getElementById('device-led-count');
|
||||
const body: any = { name, url, device_type: deviceType };
|
||||
const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
|
||||
if (ledCountInput && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
const baudRateSelect = document.getElementById('device-baud-rate');
|
||||
const baudRateSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
|
||||
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
|
||||
body.baud_rate = parseInt(baudRateSelect.value, 10);
|
||||
}
|
||||
if (isMockDevice(deviceType)) {
|
||||
const sendLatency = document.getElementById('device-send-latency')?.value;
|
||||
const sendLatency = (document.getElementById('device-send-latency') as HTMLInputElement)?.value;
|
||||
if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10);
|
||||
const ledType = document.getElementById('device-led-type')?.value;
|
||||
const ledType = (document.getElementById('device-led-type') as HTMLSelectElement)?.value;
|
||||
body.rgbw = ledType === 'rgbw';
|
||||
}
|
||||
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
|
||||
body.zone_mode = _getZoneMode();
|
||||
}
|
||||
if (isDmxDevice(deviceType)) {
|
||||
body.dmx_protocol = document.getElementById('device-dmx-protocol')?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt(document.getElementById('device-dmx-start-universe')?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt(document.getElementById('device-dmx-start-channel')?.value || '1', 10);
|
||||
body.dmx_protocol = (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt((document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt((document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1', 10);
|
||||
}
|
||||
if (isEspnowDevice(deviceType)) {
|
||||
body.espnow_peer_mac = document.getElementById('device-espnow-peer-mac')?.value || '';
|
||||
body.espnow_channel = parseInt(document.getElementById('device-espnow-channel')?.value || '1', 10);
|
||||
body.baud_rate = parseInt(document.getElementById('device-baud-rate')?.value || '921600', 10);
|
||||
body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || '';
|
||||
body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10);
|
||||
body.baud_rate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement)?.value || '921600', 10);
|
||||
}
|
||||
if (isHueDevice(deviceType)) {
|
||||
body.hue_username = document.getElementById('device-hue-username')?.value || '';
|
||||
body.hue_client_key = document.getElementById('device-hue-client-key')?.value || '';
|
||||
body.hue_entertainment_group_id = document.getElementById('device-hue-group-id')?.value || '';
|
||||
body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
|
||||
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
|
||||
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
|
||||
}
|
||||
if (isSpiDevice(deviceType)) {
|
||||
body.spi_speed_hz = parseInt(document.getElementById('device-spi-speed')?.value || '800000', 10);
|
||||
body.spi_led_type = document.getElementById('device-spi-led-type')?.value || 'WS2812B';
|
||||
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
|
||||
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
|
||||
}
|
||||
if (isChromaDevice(deviceType)) {
|
||||
body.chroma_device_type = document.getElementById('device-chroma-device-type')?.value || 'chromalink';
|
||||
body.chroma_device_type = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
|
||||
}
|
||||
if (isGameSenseDevice(deviceType)) {
|
||||
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard';
|
||||
body.gamesense_device_type = (document.getElementById('device-gamesense-device-type') as HTMLSelectElement)?.value || 'keyboard';
|
||||
}
|
||||
const csptId = document.getElementById('device-css-processing-template')?.value;
|
||||
const csptId = (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value;
|
||||
if (csptId) body.default_css_processing_template_id = csptId;
|
||||
if (lastTemplateId) body.capture_template_id = lastTemplateId;
|
||||
|
||||
@@ -872,11 +872,11 @@ export async function handleAddDevice(event) {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to add device:', errorData);
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('device_discovery.error.add_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to add device:', err);
|
||||
showToast(err.message || t('device_discovery.error.add_failed'), 'error');
|
||||
@@ -891,8 +891,8 @@ export async function handleAddDevice(event) {
|
||||
* @param {string} containerId - ID of the zone checkbox list container
|
||||
* @param {string[]} [preChecked=[]] - Zone names to pre-check
|
||||
*/
|
||||
export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) {
|
||||
const container = document.getElementById(containerId);
|
||||
export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChecked: any = []) {
|
||||
const container = document.getElementById(containerId) as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
|
||||
@@ -906,18 +906,18 @@ export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = [])
|
||||
}
|
||||
const data = await resp.json();
|
||||
_renderZoneCheckboxes(container, data.zones, preChecked);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderZoneCheckboxes(container, zones, preChecked = []) {
|
||||
function _renderZoneCheckboxes(container: any, zones: any, preChecked: any = []) {
|
||||
container.innerHTML = '';
|
||||
container._zonesData = zones;
|
||||
const preSet = new Set(preChecked.map(n => n.toLowerCase()));
|
||||
const preSet = new Set(preChecked.map((n: any) => n.toLowerCase()));
|
||||
|
||||
zones.forEach(zone => {
|
||||
zones.forEach((zone: any) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'zone-checkbox-item';
|
||||
|
||||
@@ -943,44 +943,44 @@ function _renderZoneCheckboxes(container, zones, preChecked = []) {
|
||||
_updateZoneModeVisibility(container.id);
|
||||
}
|
||||
|
||||
export function _getCheckedZones(containerId) {
|
||||
export function _getCheckedZones(containerId: any) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return [];
|
||||
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
.map(cb => (cb as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an OpenRGB URL into base URL (without zones) and zone names.
|
||||
* E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" → { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] }
|
||||
*/
|
||||
export function _splitOpenrgbZone(url) {
|
||||
export function _splitOpenrgbZone(url: any) {
|
||||
if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] };
|
||||
const stripped = url.slice('openrgb://'.length);
|
||||
const parts = stripped.split('/');
|
||||
// parts: [host:port, device_index, ...zone_str]
|
||||
if (parts.length >= 3) {
|
||||
const zoneStr = parts.slice(2).join('/');
|
||||
const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean);
|
||||
const zones = zoneStr.split('+').map((z: any) => z.trim()).filter(Boolean);
|
||||
const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1];
|
||||
return { baseUrl, zones };
|
||||
}
|
||||
return { baseUrl: url, zones: [] };
|
||||
}
|
||||
|
||||
function _appendZonesToUrl(baseUrl, zones) {
|
||||
function _appendZonesToUrl(baseUrl: any, zones: any) {
|
||||
// Strip any existing zone suffix
|
||||
const { baseUrl: clean } = _splitOpenrgbZone(baseUrl);
|
||||
return clean + '/' + zones.join('+');
|
||||
}
|
||||
|
||||
/** Show/hide zone mode toggle based on how many zones are checked. */
|
||||
export function _updateZoneModeVisibility(containerId) {
|
||||
export function _updateZoneModeVisibility(containerId: any) {
|
||||
const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group'
|
||||
: containerId === 'settings-zone-list' ? 'settings-zone-mode-group'
|
||||
: null;
|
||||
if (!modeGroupId) return;
|
||||
const modeGroup = document.getElementById(modeGroupId);
|
||||
const modeGroup = document.getElementById(modeGroupId) as HTMLElement;
|
||||
if (!modeGroup) return;
|
||||
const checkedCount = _getCheckedZones(containerId).length;
|
||||
modeGroup.style.display = checkedCount >= 2 ? '' : 'none';
|
||||
@@ -988,55 +988,55 @@ export function _updateZoneModeVisibility(containerId) {
|
||||
|
||||
/** Get the selected zone mode radio value ('combined' or 'separate'). */
|
||||
export function _getZoneMode(radioName = 'device-zone-mode') {
|
||||
const radio = document.querySelector(`input[name="${radioName}"]:checked`);
|
||||
const radio = document.querySelector(`input[name="${radioName}"]:checked`) as HTMLInputElement;
|
||||
return radio ? radio.value : 'combined';
|
||||
}
|
||||
|
||||
/* ── New device type field visibility helpers ──────────────────── */
|
||||
|
||||
function _showEspnowFields(show) {
|
||||
function _showEspnowFields(show: boolean) {
|
||||
const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group'];
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const el = document.getElementById(id) as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _showHueFields(show) {
|
||||
function _showHueFields(show: boolean) {
|
||||
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const el = document.getElementById(id) as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _showSpiFields(show) {
|
||||
function _showSpiFields(show: boolean) {
|
||||
const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const el = document.getElementById(id) as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _showChromaFields(show) {
|
||||
const el = document.getElementById('device-chroma-device-type-group');
|
||||
function _showChromaFields(show: boolean) {
|
||||
const el = document.getElementById('device-chroma-device-type-group') as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function _showGameSenseFields(show) {
|
||||
const el = document.getElementById('device-gamesense-device-type-group');
|
||||
function _showGameSenseFields(show: boolean) {
|
||||
const el = document.getElementById('device-gamesense-device-type-group') as HTMLElement;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Clone device ──────────────────────────────────────────────── */
|
||||
|
||||
export async function cloneDevice(deviceId) {
|
||||
export async function cloneDevice(deviceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load device');
|
||||
const device = await resp.json();
|
||||
showAddDevice(device.device_type || 'wled', device);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone device:', error);
|
||||
showToast(t('device.error.clone_failed'), 'error');
|
||||
@@ -5,33 +5,34 @@
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { getBaseOrigin } from './settings.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import type { Device } from '../types.ts';
|
||||
|
||||
let _deviceTagsInput = null;
|
||||
let _settingsCsptEntitySelect = null;
|
||||
let _deviceTagsInput: any = null;
|
||||
let _settingsCsptEntitySelect: any = null;
|
||||
|
||||
function _ensureSettingsCsptSelect() {
|
||||
const sel = document.getElementById('settings-css-processing-template');
|
||||
const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const templates = csptCache.data || [];
|
||||
sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
|
||||
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
||||
if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_settingsCsptEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (csptCache.data || []).map(tp => ({
|
||||
getItems: () => (csptCache.data || []).map((tp: any) => ({
|
||||
value: tp.id,
|
||||
label: tp.name,
|
||||
icon: ICON_TEMPLATE,
|
||||
@@ -40,7 +41,7 @@ function _ensureSettingsCsptSelect() {
|
||||
placeholder: window.t ? t('palette.search') : 'Search...',
|
||||
allowNone: true,
|
||||
noneLabel: t('common.none_no_cspt'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,40 +49,40 @@ class DeviceSettingsModal extends Modal {
|
||||
constructor() { super('device-settings-modal'); }
|
||||
|
||||
deviceType = '';
|
||||
capabilities = [];
|
||||
capabilities: string[] = [];
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: this.$('settings-device-name').value,
|
||||
name: (this.$('settings-device-name') as HTMLInputElement).value,
|
||||
url: this._getUrl(),
|
||||
state_check_interval: this.$('settings-health-interval').value,
|
||||
auto_shutdown: this.$('settings-auto-shutdown').checked,
|
||||
led_count: this.$('settings-led-count').value,
|
||||
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
|
||||
send_latency: document.getElementById('settings-send-latency')?.value || '0',
|
||||
state_check_interval: (this.$('settings-health-interval') as HTMLInputElement).value,
|
||||
auto_shutdown: (this.$('settings-auto-shutdown') as HTMLInputElement).checked,
|
||||
led_count: (this.$('settings-led-count') as HTMLInputElement).value,
|
||||
led_type: (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value || 'rgb',
|
||||
send_latency: (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
|
||||
zoneMode: _getZoneMode('settings-zone-mode'),
|
||||
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
|
||||
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1',
|
||||
csptId: document.getElementById('settings-css-processing-template')?.value || '',
|
||||
dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet',
|
||||
dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0',
|
||||
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
|
||||
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
_getUrl() {
|
||||
if (isMockDevice(this.deviceType)) {
|
||||
const deviceId = this.$('settings-device-id')?.value || '';
|
||||
const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || '';
|
||||
return `mock://${deviceId}`;
|
||||
}
|
||||
if (isWsDevice(this.deviceType)) {
|
||||
const deviceId = this.$('settings-device-id')?.value || '';
|
||||
const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || '';
|
||||
return `ws://${deviceId}`;
|
||||
}
|
||||
if (isSerialDevice(this.deviceType)) {
|
||||
return this.$('settings-serial-port').value;
|
||||
return (this.$('settings-serial-port') as HTMLSelectElement).value;
|
||||
}
|
||||
let url = this.$('settings-device-url').value.trim();
|
||||
let url = (this.$('settings-device-url') as HTMLInputElement).value.trim();
|
||||
// Append selected zones for OpenRGB
|
||||
if (isOpenrgbDevice(this.deviceType)) {
|
||||
const zones = _getCheckedZones('settings-zone-list');
|
||||
@@ -96,7 +97,7 @@ class DeviceSettingsModal extends Modal {
|
||||
|
||||
const settingsModal = new DeviceSettingsModal();
|
||||
|
||||
export function formatRelativeTime(isoString) {
|
||||
export function formatRelativeTime(isoString: any) {
|
||||
if (!isoString) return null;
|
||||
const then = new Date(isoString);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
@@ -112,7 +113,7 @@ export function formatRelativeTime(isoString) {
|
||||
return t('device.last_seen.days').replace('%d', diffDay);
|
||||
}
|
||||
|
||||
export function createDeviceCard(device) {
|
||||
export function createDeviceCard(device: Device & { state?: any }) {
|
||||
const state = device.state || {};
|
||||
|
||||
const devOnline = state.device_online || false;
|
||||
@@ -164,7 +165,7 @@ export function createDeviceCard(device) {
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||
${openrgbZones.length
|
||||
? openrgbZones.map(z => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
|
||||
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
|
||||
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
|
||||
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
@@ -193,7 +194,7 @@ export function createDeviceCard(device) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function turnOffDevice(deviceId) {
|
||||
export async function turnOffDevice(deviceId: any) {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
@@ -207,14 +208,14 @@ export async function turnOffDevice(deviceId) {
|
||||
const error = await setResp.json();
|
||||
showToast(error.detail || 'Failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.error.power_off_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingDevice(deviceId) {
|
||||
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`);
|
||||
export async function pingDevice(deviceId: any) {
|
||||
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`) as HTMLElement | null;
|
||||
if (btn) btn.classList.add('spinning');
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
|
||||
@@ -231,7 +232,7 @@ export async function pingDevice(deviceId) {
|
||||
const err = await resp.json();
|
||||
showToast(err.detail || 'Ping failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.ping.error'), 'error');
|
||||
} finally {
|
||||
@@ -239,11 +240,11 @@ export async function pingDevice(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function attachDeviceListeners(deviceId) {
|
||||
export function attachDeviceListeners(deviceId: any) {
|
||||
// Add any specific event listeners here if needed
|
||||
}
|
||||
|
||||
export async function removeDevice(deviceId) {
|
||||
export async function removeDevice(deviceId: any) {
|
||||
const confirmed = await showConfirm(t('device.remove.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -259,14 +260,14 @@ export async function removeDevice(deviceId) {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to remove device:', error);
|
||||
showToast(t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showSettings(deviceId) {
|
||||
export async function showSettings(deviceId: any) {
|
||||
try {
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
|
||||
@@ -279,18 +280,18 @@ export async function showSettings(deviceId) {
|
||||
settingsModal.deviceType = device.device_type;
|
||||
settingsModal.capabilities = caps;
|
||||
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
||||
(document.getElementById('settings-device-id') as HTMLInputElement).value = device.id;
|
||||
(document.getElementById('settings-device-name') as HTMLInputElement).value = device.name;
|
||||
(document.getElementById('settings-health-interval') as HTMLInputElement).value = device.state_check_interval ?? 30;
|
||||
|
||||
const isMock = isMockDevice(device.device_type);
|
||||
const isWs = isWsDevice(device.device_type);
|
||||
const isMqtt = isMqttDevice(device.device_type);
|
||||
const urlGroup = document.getElementById('settings-url-group');
|
||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||
const urlHint = urlGroup.querySelector('.input-hint');
|
||||
const urlInput = document.getElementById('settings-device-url');
|
||||
const urlGroup = document.getElementById('settings-url-group') as HTMLElement;
|
||||
const serialGroup = document.getElementById('settings-serial-port-group') as HTMLElement;
|
||||
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
|
||||
const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
const urlInput = document.getElementById('settings-device-url') as HTMLInputElement;
|
||||
if (isMock || isWs) {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
@@ -324,18 +325,18 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||
const ledCountGroup = document.getElementById('settings-led-count-group') as HTMLElement;
|
||||
if (caps.includes('manual_led_count')) {
|
||||
ledCountGroup.style.display = '';
|
||||
document.getElementById('settings-led-count').value = device.led_count || '';
|
||||
(document.getElementById('settings-led-count') as HTMLInputElement).value = device.led_count || '';
|
||||
} else {
|
||||
ledCountGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
const baudRateGroup = document.getElementById('settings-baud-rate-group');
|
||||
const baudRateGroup = document.getElementById('settings-baud-rate-group') as HTMLElement;
|
||||
if (isAdalight) {
|
||||
baudRateGroup.style.display = '';
|
||||
const baudSelect = document.getElementById('settings-baud-rate');
|
||||
const baudSelect = document.getElementById('settings-baud-rate') as HTMLSelectElement;
|
||||
if (device.baud_rate) {
|
||||
baudSelect.value = String(device.baud_rate);
|
||||
} else {
|
||||
@@ -351,16 +352,16 @@ export async function showSettings(deviceId) {
|
||||
const sendLatencyGroup = document.getElementById('settings-send-latency-group');
|
||||
if (isMock) {
|
||||
if (ledTypeGroup) {
|
||||
ledTypeGroup.style.display = '';
|
||||
document.getElementById('settings-led-type').value = device.rgbw ? 'rgbw' : 'rgb';
|
||||
(ledTypeGroup as HTMLElement).style.display = '';
|
||||
(document.getElementById('settings-led-type') as HTMLSelectElement).value = device.rgbw ? 'rgbw' : 'rgb';
|
||||
}
|
||||
if (sendLatencyGroup) {
|
||||
sendLatencyGroup.style.display = '';
|
||||
document.getElementById('settings-send-latency').value = device.send_latency_ms || 0;
|
||||
(sendLatencyGroup as HTMLElement).style.display = '';
|
||||
(document.getElementById('settings-send-latency') as HTMLInputElement).value = device.send_latency_ms || 0;
|
||||
}
|
||||
} else {
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (ledTypeGroup) (ledTypeGroup as HTMLElement).style.display = 'none';
|
||||
if (sendLatencyGroup) (sendLatencyGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// WS connection URL
|
||||
@@ -372,45 +373,45 @@ export async function showSettings(deviceId) {
|
||||
const hostPart = origin.replace(/^https?:\/\//, '');
|
||||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||
const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
document.getElementById('settings-ws-url').value = wsUrl;
|
||||
wsUrlGroup.style.display = '';
|
||||
(document.getElementById('settings-ws-url') as HTMLInputElement).value = wsUrl;
|
||||
(wsUrlGroup as HTMLElement).style.display = '';
|
||||
} else {
|
||||
wsUrlGroup.style.display = 'none';
|
||||
(wsUrlGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hide health check for devices without health_check capability
|
||||
const healthIntervalGroup = document.getElementById('settings-health-interval-group');
|
||||
if (healthIntervalGroup) {
|
||||
healthIntervalGroup.style.display = caps.includes('health_check') ? '' : 'none';
|
||||
(healthIntervalGroup as HTMLElement).style.display = caps.includes('health_check') ? '' : 'none';
|
||||
}
|
||||
|
||||
// Hide auto-restore for devices without auto_restore capability
|
||||
const autoShutdownGroup = document.getElementById('settings-auto-shutdown-group');
|
||||
if (autoShutdownGroup) {
|
||||
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none';
|
||||
(autoShutdownGroup as HTMLElement).style.display = caps.includes('auto_restore') ? '' : 'none';
|
||||
}
|
||||
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
||||
(document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked = !!device.auto_shutdown;
|
||||
|
||||
// OpenRGB zone picker + mode toggle
|
||||
const settingsZoneGroup = document.getElementById('settings-zone-group');
|
||||
const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group');
|
||||
if (settingsZoneModeGroup) settingsZoneModeGroup.style.display = 'none';
|
||||
if (settingsZoneModeGroup) (settingsZoneModeGroup as HTMLElement).style.display = 'none';
|
||||
if (settingsZoneGroup) {
|
||||
if (isOpenrgbDevice(device.device_type)) {
|
||||
settingsZoneGroup.style.display = '';
|
||||
(settingsZoneGroup as HTMLElement).style.display = '';
|
||||
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
|
||||
// Set zone mode radio from device
|
||||
const savedMode = device.zone_mode || 'combined';
|
||||
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`);
|
||||
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`) as HTMLInputElement | null;
|
||||
if (modeRadio) modeRadio.checked = true;
|
||||
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
|
||||
// Re-snapshot after zones are loaded so dirty-check baseline includes them
|
||||
settingsModal.snapshot();
|
||||
});
|
||||
} else {
|
||||
settingsZoneGroup.style.display = 'none';
|
||||
document.getElementById('settings-zone-list').innerHTML = '';
|
||||
(settingsZoneGroup as HTMLElement).style.display = 'none';
|
||||
(document.getElementById('settings-zone-list') as HTMLElement).innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,24 +420,24 @@ export async function showSettings(deviceId) {
|
||||
const dmxStartUniverseGroup = document.getElementById('settings-dmx-start-universe-group');
|
||||
const dmxStartChannelGroup = document.getElementById('settings-dmx-start-channel-group');
|
||||
if (isDmxDevice(device.device_type)) {
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = '';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = '';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = '';
|
||||
document.getElementById('settings-dmx-protocol').value = device.dmx_protocol || 'artnet';
|
||||
if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = '';
|
||||
if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = '';
|
||||
if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = '';
|
||||
(document.getElementById('settings-dmx-protocol') as HTMLSelectElement).value = device.dmx_protocol || 'artnet';
|
||||
ensureDmxProtocolIconSelect('settings-dmx-protocol');
|
||||
document.getElementById('settings-dmx-start-universe').value = device.dmx_start_universe ?? 0;
|
||||
document.getElementById('settings-dmx-start-channel').value = device.dmx_start_channel ?? 1;
|
||||
(document.getElementById('settings-dmx-start-universe') as HTMLInputElement).value = device.dmx_start_universe ?? 0;
|
||||
(document.getElementById('settings-dmx-start-channel') as HTMLInputElement).value = device.dmx_start_channel ?? 1;
|
||||
// Relabel URL field as IP Address
|
||||
const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||
const urlHint2 = urlGroup.querySelector('.input-hint');
|
||||
const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
|
||||
const urlHint2 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
if (urlLabel2) urlLabel2.textContent = t('device.dmx.url');
|
||||
if (urlHint2) urlHint2.textContent = t('device.dmx.url.hint');
|
||||
urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50';
|
||||
} else {
|
||||
destroyDmxProtocolIconSelect('settings-dmx-protocol');
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = 'none';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none';
|
||||
if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = 'none';
|
||||
if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = 'none';
|
||||
if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// Tags
|
||||
@@ -449,7 +450,7 @@ export async function showSettings(deviceId) {
|
||||
// CSPT template selector
|
||||
await csptCache.fetch();
|
||||
_ensureSettingsCsptSelect();
|
||||
const csptSel = document.getElementById('settings-css-processing-template');
|
||||
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
|
||||
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
|
||||
|
||||
settingsModal.snapshot();
|
||||
@@ -457,7 +458,7 @@ export async function showSettings(deviceId) {
|
||||
|
||||
setTimeout(() => desktopFocus(document.getElementById('settings-device-name')), 100);
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load device settings:', error);
|
||||
showToast(t('device.error.settings_load_failed'), 'error');
|
||||
@@ -469,8 +470,8 @@ export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _devic
|
||||
export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
|
||||
const url = settingsModal._getUrl();
|
||||
|
||||
if (!name || !url) {
|
||||
@@ -479,35 +480,35 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
const body: any = {
|
||||
name, url,
|
||||
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
|
||||
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
|
||||
auto_shutdown: (document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked,
|
||||
state_check_interval: parseInt((document.getElementById('settings-health-interval') as HTMLInputElement).value, 10) || 30,
|
||||
tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [],
|
||||
};
|
||||
const ledCountInput = document.getElementById('settings-led-count');
|
||||
const ledCountInput = document.getElementById('settings-led-count') as HTMLInputElement;
|
||||
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
if (isSerialDevice(settingsModal.deviceType)) {
|
||||
const baudVal = document.getElementById('settings-baud-rate').value;
|
||||
const baudVal = (document.getElementById('settings-baud-rate') as HTMLSelectElement).value;
|
||||
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
||||
}
|
||||
if (isMockDevice(settingsModal.deviceType)) {
|
||||
const sendLatency = document.getElementById('settings-send-latency')?.value;
|
||||
const sendLatency = (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value;
|
||||
if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10);
|
||||
const ledType = document.getElementById('settings-led-type')?.value;
|
||||
const ledType = (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value;
|
||||
body.rgbw = ledType === 'rgbw';
|
||||
}
|
||||
if (isOpenrgbDevice(settingsModal.deviceType)) {
|
||||
body.zone_mode = _getZoneMode('settings-zone-mode');
|
||||
}
|
||||
if (isDmxDevice(settingsModal.deviceType)) {
|
||||
body.dmx_protocol = document.getElementById('settings-dmx-protocol')?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10);
|
||||
body.dmx_protocol = (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10);
|
||||
}
|
||||
const csptId = document.getElementById('settings-css-processing-template')?.value || '';
|
||||
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
|
||||
body.default_css_processing_template_id = csptId;
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
@@ -517,7 +518,7 @@ export async function saveDeviceSettings() {
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
settingsModal.showError(detailStr || t('device.error.update'));
|
||||
return;
|
||||
}
|
||||
@@ -526,7 +527,7 @@ export async function saveDeviceSettings() {
|
||||
devicesCache.invalidate();
|
||||
settingsModal.forceClose();
|
||||
window.loadDevices();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to save device settings:', err);
|
||||
settingsModal.showError(err.message || t('device.error.save'));
|
||||
@@ -534,12 +535,12 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
// Brightness
|
||||
export function updateBrightnessLabel(deviceId, value) {
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
export function updateBrightnessLabel(deviceId: any, value: any) {
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLElement | null;
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
}
|
||||
|
||||
export async function saveCardBrightness(deviceId, value) {
|
||||
export async function saveCardBrightness(deviceId: any, value: any) {
|
||||
const bri = parseInt(value);
|
||||
updateDeviceBrightness(deviceId, bri);
|
||||
try {
|
||||
@@ -550,17 +551,17 @@ export async function saveCardBrightness(deviceId, value) {
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('device.error.brightness'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
showToast(err.message || t('device.error.brightness'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const _brightnessFetchInFlight = new Set();
|
||||
export async function fetchDeviceBrightness(deviceId) {
|
||||
export async function fetchDeviceBrightness(deviceId: any) {
|
||||
if (_brightnessFetchInFlight.has(deviceId)) return;
|
||||
_brightnessFetchInFlight.add(deviceId);
|
||||
try {
|
||||
@@ -568,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId) {
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
updateDeviceBrightness(deviceId, data.brightness);
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
slider.value = data.brightness;
|
||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||||
slider.disabled = false;
|
||||
}
|
||||
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
|
||||
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`) as HTMLElement | null;
|
||||
if (wrap) wrap.classList.remove('brightness-loading');
|
||||
} catch (err) {
|
||||
// Silently fail — device may be offline
|
||||
@@ -597,7 +598,7 @@ const ADALIGHT_HEADER_BYTES = 6; // 'Ada' + count_hi + count_lo + check
|
||||
const AMBILED_HEADER_BYTES = 1;
|
||||
|
||||
// FPS hint helpers (shared with device-discovery, targets)
|
||||
export function _computeMaxFps(baudRate, ledCount, deviceType) {
|
||||
export function _computeMaxFps(baudRate: any, ledCount: any, deviceType: any) {
|
||||
if (!ledCount || ledCount < 1) return null;
|
||||
if (deviceType === 'wled') {
|
||||
const frameUs = ledCount * LED_US_PER_PIXEL + LED_RESET_US;
|
||||
@@ -609,7 +610,7 @@ export function _computeMaxFps(baudRate, ledCount, deviceType) {
|
||||
return Math.floor(baudRate / bitsPerFrame);
|
||||
}
|
||||
|
||||
export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
|
||||
export function _renderFpsHint(hintEl: any, baudRate: any, ledCount: any, deviceType: any) {
|
||||
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
|
||||
if (fps !== null) {
|
||||
hintEl.textContent = `Max FPS ≈ ${fps}`;
|
||||
@@ -621,14 +622,14 @@ export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
|
||||
|
||||
export function updateSettingsBaudFpsHint() {
|
||||
const hintEl = document.getElementById('settings-baud-fps-hint');
|
||||
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
|
||||
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
|
||||
const baudRate = parseInt((document.getElementById('settings-baud-rate') as HTMLSelectElement).value, 10);
|
||||
const ledCount = parseInt((document.getElementById('settings-led-count') as HTMLInputElement).value, 10);
|
||||
_renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType);
|
||||
}
|
||||
|
||||
// Settings serial port population (used from showSettings)
|
||||
async function _populateSettingsSerialPorts(currentUrl) {
|
||||
const select = document.getElementById('settings-serial-port');
|
||||
async function _populateSettingsSerialPorts(currentUrl: any) {
|
||||
const select = document.getElementById('settings-serial-port') as HTMLSelectElement;
|
||||
select.innerHTML = '';
|
||||
const loadingOpt = document.createElement('option');
|
||||
loadingOpt.value = currentUrl;
|
||||
@@ -644,7 +645,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
||||
|
||||
select.innerHTML = '';
|
||||
let currentFound = false;
|
||||
devices.forEach(device => {
|
||||
devices.forEach((device: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.url;
|
||||
opt.textContent = device.name;
|
||||
@@ -664,7 +665,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
||||
}
|
||||
|
||||
export function copyWsUrl() {
|
||||
const input = document.getElementById('settings-ws-url');
|
||||
const input = document.getElementById('settings-ws-url') as HTMLInputElement | null;
|
||||
if (!input || !input.value) return;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
@@ -686,10 +687,10 @@ document.addEventListener('auth:keyChanged', () => loadDevices());
|
||||
// ===== OpenRGB zone count enrichment =====
|
||||
|
||||
// Cache: baseUrl → { zoneName: ledCount, ... }
|
||||
const _zoneCountCache = {};
|
||||
const _zoneCountCache: any = {};
|
||||
|
||||
/** Return cached zone LED counts for a base URL, or null if not cached. */
|
||||
export function getZoneCountCache(baseUrl) {
|
||||
export function getZoneCountCache(baseUrl: any) {
|
||||
return _zoneCountCache[baseUrl] || null;
|
||||
}
|
||||
const _zoneCountInFlight = new Set();
|
||||
@@ -698,7 +699,7 @@ const _zoneCountInFlight = new Set();
|
||||
* Fetch zone LED counts for an OpenRGB device and update zone badges on the card.
|
||||
* Called after cards are rendered (same pattern as fetchDeviceBrightness).
|
||||
*/
|
||||
export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
|
||||
export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
|
||||
const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl);
|
||||
if (!zones.length) return;
|
||||
|
||||
@@ -716,7 +717,7 @@ export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const counts = {};
|
||||
const counts: any = {};
|
||||
for (const z of data.zones) {
|
||||
counts[z.name.toLowerCase()] = z.led_count;
|
||||
}
|
||||
@@ -729,7 +730,7 @@ export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function _applyZoneCounts(deviceId, zones, counts) {
|
||||
function _applyZoneCounts(deviceId: any, zones: any, counts: any) {
|
||||
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
|
||||
if (!card) return;
|
||||
for (const zoneName of zones) {
|
||||
@@ -7,19 +7,20 @@ import {
|
||||
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
|
||||
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
|
||||
availableEngines,
|
||||
} from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { fetchWithAuth } from '../core/api.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
} from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import type { Display } from '../types.ts';
|
||||
|
||||
/** Currently active engine type for the picker (null = desktop monitors). */
|
||||
let _pickerEngineType = null;
|
||||
let _pickerEngineType: string | null = null;
|
||||
|
||||
/** Check if an engine type has its own device list (for inline onclick use). */
|
||||
window._engineHasOwnDisplays = (engineType) =>
|
||||
window._engineHasOwnDisplays = (engineType: string) =>
|
||||
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
|
||||
|
||||
export function openDisplayPicker(callback, selectedIndex, engineType = null) {
|
||||
export function openDisplayPicker(callback: (index: number, display?: Display | null) => void, selectedIndex: number | string | null | undefined, engineType: string | null = null): void {
|
||||
set_displayPickerCallback(callback);
|
||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||
_pickerEngineType = engineType || null;
|
||||
@@ -53,7 +54,7 @@ export function openDisplayPicker(callback, selectedIndex, engineType = null) {
|
||||
});
|
||||
}
|
||||
|
||||
async function _fetchAndRenderEngineDisplays(engineType) {
|
||||
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
|
||||
const canvas = document.getElementById('display-picker-canvas');
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
|
||||
@@ -79,14 +80,14 @@ async function _fetchAndRenderEngineDisplays(engineType) {
|
||||
}
|
||||
}
|
||||
|
||||
function _renderEmptyAndroidPicker(canvas) {
|
||||
function _renderEmptyAndroidPicker(canvas: HTMLElement): void {
|
||||
canvas.innerHTML = `
|
||||
<div class="loading">${t('displays.picker.no_android')}</div>
|
||||
${_buildAdbConnectHtml()}
|
||||
`;
|
||||
}
|
||||
|
||||
function _buildAdbConnectHtml() {
|
||||
function _buildAdbConnectHtml(): string {
|
||||
return `
|
||||
<div class="adb-connect-form" style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; justify-content: center;">
|
||||
<input type="text" id="adb-connect-ip"
|
||||
@@ -101,7 +102,7 @@ function _buildAdbConnectHtml() {
|
||||
|
||||
/** Called from the inline Connect button inside the display picker. */
|
||||
window._adbConnectFromPicker = async function () {
|
||||
const input = document.getElementById('adb-connect-ip');
|
||||
const input = document.getElementById('adb-connect-ip') as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
const address = input.value.trim();
|
||||
if (!address) return;
|
||||
@@ -129,15 +130,15 @@ window._adbConnectFromPicker = async function () {
|
||||
}
|
||||
};
|
||||
|
||||
export function closeDisplayPicker(event) {
|
||||
if (event && event.target && event.target.closest('.display-picker-content')) return;
|
||||
export function closeDisplayPicker(event?: Event): void {
|
||||
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
|
||||
const lightbox = document.getElementById('display-picker-lightbox');
|
||||
lightbox.classList.remove('active');
|
||||
set_displayPickerCallback(null);
|
||||
_pickerEngineType = null;
|
||||
}
|
||||
|
||||
export function selectDisplay(displayIndex) {
|
||||
export function selectDisplay(displayIndex: number): void {
|
||||
// Re-read live bindings
|
||||
import('../core/state.js').then(({ _displayPickerCallback: cb, _cachedDisplays: displays }) => {
|
||||
if (cb) {
|
||||
@@ -148,7 +149,7 @@ export function selectDisplay(displayIndex) {
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDisplayPickerLayout(displays, engineType = null) {
|
||||
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): void {
|
||||
const canvas = document.getElementById('display-picker-canvas');
|
||||
|
||||
if (!displays || displays.length === 0) {
|
||||
@@ -226,7 +227,7 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function formatDisplayLabel(displayIndex, display, engineType = null) {
|
||||
export function formatDisplayLabel(displayIndex: number, display: any | null, engineType: string | null = null): string {
|
||||
if (display) {
|
||||
if (engineType && window._engineHasOwnDisplays(engineType)) {
|
||||
return `${display.name} (${display.width}×${display.height})`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,23 +12,24 @@ import {
|
||||
PATTERN_RECT_BORDERS,
|
||||
_cachedValueSources, valueSourcesCache, streamsCache,
|
||||
outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import {
|
||||
getValueSourceIcon, getPictureSourceIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP,
|
||||
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
} 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 { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import type { OutputTarget } from '../types.ts';
|
||||
|
||||
let _kcTagsInput = null;
|
||||
let _kcTagsInput: any = null;
|
||||
|
||||
class KCEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -37,13 +38,13 @@ class KCEditorModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('kc-editor-name').value,
|
||||
source: document.getElementById('kc-editor-source').value,
|
||||
fps: document.getElementById('kc-editor-fps').value,
|
||||
interpolation: document.getElementById('kc-editor-interpolation').value,
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
patternTemplateId: document.getElementById('kc-editor-pattern-template').value,
|
||||
brightness_vs: document.getElementById('kc-editor-brightness-vs').value,
|
||||
name: (document.getElementById('kc-editor-name') as HTMLInputElement).value,
|
||||
source: (document.getElementById('kc-editor-source') as HTMLSelectElement).value,
|
||||
fps: (document.getElementById('kc-editor-fps') as HTMLInputElement).value,
|
||||
interpolation: (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value,
|
||||
smoothing: (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value,
|
||||
patternTemplateId: (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value,
|
||||
brightness_vs: (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -53,12 +54,12 @@ const kcEditorModal = new KCEditorModal();
|
||||
|
||||
/* ── Visual selectors ─────────────────────────────────────────── */
|
||||
|
||||
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
let _kcColorModeIconSelect = null;
|
||||
let _kcSourceEntitySelect = null;
|
||||
let _kcPatternEntitySelect = null;
|
||||
let _kcBrightnessEntitySelect = null;
|
||||
let _kcColorModeIconSelect: any = null;
|
||||
let _kcSourceEntitySelect: any = null;
|
||||
let _kcPatternEntitySelect: any = null;
|
||||
let _kcBrightnessEntitySelect: any = null;
|
||||
|
||||
// Inline SVG previews for color modes
|
||||
const _COLOR_MODE_SVG = {
|
||||
@@ -76,35 +77,35 @@ function _ensureColorModeIconSelect() {
|
||||
{ value: 'dominant', icon: _COLOR_MODE_SVG.dominant, label: t('kc.interpolation.dominant'), desc: t('kc.interpolation.dominant.desc') },
|
||||
];
|
||||
if (_kcColorModeIconSelect) { _kcColorModeIconSelect.updateItems(items); return; }
|
||||
_kcColorModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
_kcColorModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||
}
|
||||
|
||||
function _ensureSourceEntitySelect(sources) {
|
||||
function _ensureSourceEntitySelect(sources: any) {
|
||||
const sel = document.getElementById('kc-editor-source');
|
||||
if (!sel) return;
|
||||
if (_kcSourceEntitySelect) _kcSourceEntitySelect.destroy();
|
||||
if (sources.length > 0) {
|
||||
_kcSourceEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => sources.map(s => ({
|
||||
getItems: () => sources.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getPictureSourceIcon(s.stream_type),
|
||||
desc: s.stream_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _ensurePatternEntitySelect(patTemplates) {
|
||||
function _ensurePatternEntitySelect(patTemplates: any) {
|
||||
const sel = document.getElementById('kc-editor-pattern-template');
|
||||
if (!sel) return;
|
||||
if (_kcPatternEntitySelect) _kcPatternEntitySelect.destroy();
|
||||
if (patTemplates.length > 0) {
|
||||
_kcPatternEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => patTemplates.map(pt => {
|
||||
getItems: () => patTemplates.map((pt: any) => {
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
return {
|
||||
value: pt.id,
|
||||
@@ -114,7 +115,7 @@ function _ensurePatternEntitySelect(patTemplates) {
|
||||
};
|
||||
}),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ function _ensureBrightnessEntitySelect() {
|
||||
target: sel,
|
||||
getItems: () => {
|
||||
const items = [{ value: '', label: t('kc.brightness_vs.none'), icon: _icon(P.sunDim), desc: '' }];
|
||||
return items.concat(_cachedValueSources.map(vs => ({
|
||||
return items.concat(_cachedValueSources.map((vs: any) => ({
|
||||
value: vs.id,
|
||||
label: vs.name,
|
||||
icon: getValueSourceIcon(vs.source_type),
|
||||
@@ -135,11 +136,11 @@ function _ensureBrightnessEntitySelect() {
|
||||
})));
|
||||
},
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
export function patchKCTargetMetrics(target) {
|
||||
export function patchKCTargetMetrics(target: any) {
|
||||
const card = document.querySelector(`[data-kc-target-id="${target.id}"]`);
|
||||
if (!card) return;
|
||||
const state = target.state || {};
|
||||
@@ -154,13 +155,13 @@ export function patchKCTargetMetrics(target) {
|
||||
const fpsTarget = card.querySelector('[data-tm="fps-target"]');
|
||||
if (fpsTarget) fpsTarget.textContent = state.fps_target || 0;
|
||||
|
||||
const frames = card.querySelector('[data-tm="frames"]');
|
||||
const frames = card.querySelector('[data-tm="frames"]') as HTMLElement;
|
||||
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
|
||||
|
||||
const keepalive = card.querySelector('[data-tm="keepalive"]');
|
||||
const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement;
|
||||
if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }
|
||||
|
||||
const errors = card.querySelector('[data-tm="errors"]');
|
||||
const errors = card.querySelector('[data-tm="errors"]') as HTMLElement;
|
||||
if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }
|
||||
|
||||
const uptime = card.querySelector('[data-tm="uptime"]');
|
||||
@@ -186,9 +187,9 @@ export function patchKCTargetMetrics(target) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueSourceMap) {
|
||||
export function createKCTargetCard(target: OutputTarget & { state?: any; metrics?: any; latestColors?: any }, sourceMap: Record<string, any>, patternTemplateMap: Record<string, any>, valueSourceMap: Record<string, any>) {
|
||||
const state = target.state || {};
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
const kcSettings = target.key_colors_settings ?? {} as Partial<import('../types.ts').KeyColorsSettings>;
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
const brightness = kcSettings.brightness ?? 1.0;
|
||||
@@ -207,7 +208,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
|
||||
let swatchesHtml = '';
|
||||
const latestColors = target.latestColors && target.latestColors.colors;
|
||||
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
|
||||
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
|
||||
swatchesHtml = Object.entries(latestColors).map(([name, color]: [string, any]) => `
|
||||
<div class="kc-swatch">
|
||||
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
|
||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
@@ -307,7 +308,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
|
||||
|
||||
// ===== KEY COLORS TEST =====
|
||||
|
||||
function _openKCTestWs(targetId, fps, previewWidth = 480) {
|
||||
function _openKCTestWs(targetId: any, fps: any, previewWidth = 480) {
|
||||
// Close any existing WS
|
||||
if (kcTestWs) {
|
||||
try { kcTestWs.close(); } catch (_) {}
|
||||
@@ -327,7 +328,7 @@ function _openKCTestWs(targetId, fps, previewWidth = 480) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'frame') {
|
||||
// Hide spinner on first frame
|
||||
const spinner = document.querySelector('.lightbox-spinner');
|
||||
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
displayKCTestResults(data);
|
||||
}
|
||||
@@ -357,37 +358,37 @@ function _openKCTestWs(targetId, fps, previewWidth = 480) {
|
||||
setKcTestTargetId(targetId);
|
||||
}
|
||||
|
||||
export async function testKCTarget(targetId) {
|
||||
export async function testKCTarget(targetId: any) {
|
||||
setKcTestTargetId(targetId);
|
||||
|
||||
// Show lightbox immediately with a spinner
|
||||
const lightbox = document.getElementById('image-lightbox');
|
||||
const lbImg = document.getElementById('lightbox-image');
|
||||
const statsEl = document.getElementById('lightbox-stats');
|
||||
const lightbox = document.getElementById('image-lightbox')!;
|
||||
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
|
||||
lbImg.style.display = 'none';
|
||||
lbImg.src = '';
|
||||
statsEl.style.display = 'none';
|
||||
|
||||
// Insert spinner if not already present
|
||||
let spinner = lightbox.querySelector('.lightbox-spinner');
|
||||
let spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (!spinner) {
|
||||
spinner = document.createElement('div');
|
||||
spinner.className = 'lightbox-spinner loading-spinner';
|
||||
lightbox.querySelector('.lightbox-content').prepend(spinner);
|
||||
lightbox.querySelector('.lightbox-content')!.prepend(spinner);
|
||||
}
|
||||
spinner.style.display = '';
|
||||
|
||||
// Hide controls — KC test streams automatically
|
||||
const refreshBtn = document.getElementById('lightbox-auto-refresh');
|
||||
const refreshBtn = document.getElementById('lightbox-auto-refresh') as HTMLElement;
|
||||
if (refreshBtn) refreshBtn.style.display = 'none';
|
||||
const fpsSelect = document.getElementById('lightbox-fps-select');
|
||||
const fpsSelect = document.getElementById('lightbox-fps-select') as HTMLElement;
|
||||
if (fpsSelect) fpsSelect.style.display = 'none';
|
||||
|
||||
lightbox.classList.add('active');
|
||||
lockBody();
|
||||
|
||||
// Use same FPS from CSS test settings and dynamic preview resolution
|
||||
const fps = parseInt(localStorage.getItem('css_test_fps')) || 15;
|
||||
const fps = parseInt(localStorage.getItem('css_test_fps')!) || 15;
|
||||
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||||
_openKCTestWs(targetId, fps, previewWidth);
|
||||
}
|
||||
@@ -404,13 +405,13 @@ export function stopKCTestAutoRefresh() {
|
||||
setKcTestTargetId(null);
|
||||
}
|
||||
|
||||
export function displayKCTestResults(result) {
|
||||
export function displayKCTestResults(result: any) {
|
||||
const srcImg = new window.Image();
|
||||
srcImg.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = srcImg.width;
|
||||
canvas.height = srcImg.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Draw captured frame
|
||||
ctx.drawImage(srcImg, 0, 0);
|
||||
@@ -419,7 +420,7 @@ export function displayKCTestResults(result) {
|
||||
const h = srcImg.height;
|
||||
|
||||
// Draw each rectangle with extracted color overlay
|
||||
result.rectangles.forEach((rect, i) => {
|
||||
result.rectangles.forEach((rect: any, i: number) => {
|
||||
const px = rect.x * w;
|
||||
const py = rect.y * h;
|
||||
const pw = rect.width * w;
|
||||
@@ -466,7 +467,7 @@ export function displayKCTestResults(result) {
|
||||
// Build stats HTML
|
||||
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
|
||||
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
|
||||
result.rectangles.forEach((rect) => {
|
||||
result.rectangles.forEach((rect: any) => {
|
||||
const c = rect.color;
|
||||
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
|
||||
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
|
||||
@@ -476,11 +477,11 @@ export function displayKCTestResults(result) {
|
||||
statsHtml += `</div>`;
|
||||
|
||||
// Hide spinner, show result in the already-open lightbox
|
||||
const spinner = document.querySelector('.lightbox-spinner');
|
||||
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
|
||||
const lbImg = document.getElementById('lightbox-image');
|
||||
const statsEl = document.getElementById('lightbox-stats');
|
||||
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
|
||||
lbImg.src = dataUrl;
|
||||
lbImg.style.display = '';
|
||||
statsEl.innerHTML = statsHtml;
|
||||
@@ -493,22 +494,22 @@ export function displayKCTestResults(result) {
|
||||
|
||||
function _autoGenerateKCName() {
|
||||
if (_kcNameManuallyEdited) return;
|
||||
if (document.getElementById('kc-editor-id').value) return;
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
if ((document.getElementById('kc-editor-id') as HTMLInputElement).value) return;
|
||||
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!sourceName) return;
|
||||
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
||||
const mode = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value || 'average';
|
||||
const modeName = t(`kc.interpolation.${mode}`);
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
|
||||
const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`;
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = `${sourceName} \u00b7 ${patName} (${modeName})`;
|
||||
}
|
||||
|
||||
function _populateKCBrightnessVsDropdown(selectedId = '') {
|
||||
const sel = document.getElementById('kc-editor-brightness-vs');
|
||||
const sel = document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement;
|
||||
// Keep the first "None" option, remove the rest
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
_cachedValueSources.forEach(vs => {
|
||||
_cachedValueSources.forEach((vs: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = vs.id;
|
||||
opt.textContent = vs.name;
|
||||
@@ -518,7 +519,7 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
|
||||
_ensureBrightnessEntitySelect();
|
||||
}
|
||||
|
||||
export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
export async function showKCEditor(targetId: any = null, cloneData: any = null) {
|
||||
try {
|
||||
// Load sources, pattern templates, and value sources in parallel
|
||||
const [sources, patTemplates, valueSources] = await Promise.all([
|
||||
@@ -528,9 +529,9 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
]);
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
sources.forEach((s: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
@@ -539,9 +540,9 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
});
|
||||
|
||||
// Populate pattern template select
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
|
||||
patSelect.innerHTML = '';
|
||||
patTemplates.forEach(pt => {
|
||||
patTemplates.forEach((pt: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pt.id;
|
||||
opt.dataset.name = pt.name;
|
||||
@@ -555,7 +556,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_ensureSourceEntitySelect(sources);
|
||||
_ensurePatternEntitySelect(patTemplates);
|
||||
|
||||
let _editorTags = [];
|
||||
let _editorTags: any[] = [];
|
||||
if (targetId) {
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
@@ -563,53 +564,53 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_editorTags = target.tags || [];
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
document.getElementById('kc-editor-id').value = target.id;
|
||||
document.getElementById('kc-editor-name').value = target.name;
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = target.id;
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = target.name;
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
|
||||
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
|
||||
patSelect.value = kcSettings.pattern_template_id || '';
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
|
||||
} else if (cloneData) {
|
||||
_editorTags = cloneData.tags || [];
|
||||
const kcSettings = cloneData.key_colors_settings || {};
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
sourceSelect.value = cloneData.picture_source_id || '';
|
||||
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
|
||||
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
|
||||
patSelect.value = kcSettings.pattern_template_id || '';
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
} else {
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = '';
|
||||
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).value = '';
|
||||
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
||||
document.getElementById('kc-editor-fps').value = 10;
|
||||
document.getElementById('kc-editor-fps-value').textContent = '10';
|
||||
document.getElementById('kc-editor-interpolation').value = 'average';
|
||||
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = '10' as any;
|
||||
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = '10';
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = 'average';
|
||||
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue('average');
|
||||
document.getElementById('kc-editor-smoothing').value = 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
|
||||
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = '0.3' as any;
|
||||
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = '0.3';
|
||||
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
|
||||
_populateKCBrightnessVsDropdown('');
|
||||
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
|
||||
}
|
||||
|
||||
// Auto-name
|
||||
set_kcNameManuallyEdited(!!(targetId || cloneData));
|
||||
document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); };
|
||||
(document.getElementById('kc-editor-name') as HTMLInputElement).oninput = () => { set_kcNameManuallyEdited(true); };
|
||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
||||
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).onchange = () => _autoGenerateKCName();
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId && !cloneData) _autoGenerateKCName();
|
||||
|
||||
@@ -623,7 +624,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
kcEditorModal.snapshot();
|
||||
kcEditorModal.open();
|
||||
|
||||
document.getElementById('kc-editor-error').style.display = 'none';
|
||||
(document.getElementById('kc-editor-error') as HTMLElement).style.display = 'none';
|
||||
setTimeout(() => desktopFocus(document.getElementById('kc-editor-name')), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to open KC editor:', error);
|
||||
@@ -647,14 +648,14 @@ export function forceCloseKCEditorModal() {
|
||||
}
|
||||
|
||||
export async function saveKCEditor() {
|
||||
const targetId = document.getElementById('kc-editor-id').value;
|
||||
const name = document.getElementById('kc-editor-name').value.trim();
|
||||
const sourceId = document.getElementById('kc-editor-source').value;
|
||||
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
|
||||
const interpolation = document.getElementById('kc-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
|
||||
const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
|
||||
const brightnessVsId = document.getElementById('kc-editor-brightness-vs').value;
|
||||
const targetId = (document.getElementById('kc-editor-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('kc-editor-name') as HTMLInputElement).value.trim();
|
||||
const sourceId = (document.getElementById('kc-editor-source') as HTMLSelectElement).value;
|
||||
const fps = parseInt((document.getElementById('kc-editor-fps') as HTMLInputElement).value) || 10;
|
||||
const interpolation = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value;
|
||||
const smoothing = parseFloat((document.getElementById('kc-editor-smoothing') as HTMLInputElement).value);
|
||||
const patternTemplateId = (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value;
|
||||
const brightnessVsId = (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value;
|
||||
|
||||
if (!name) {
|
||||
kcEditorModal.showError(t('kc.error.required'));
|
||||
@@ -666,7 +667,7 @@ export async function saveKCEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
|
||||
@@ -704,26 +705,26 @@ export async function saveKCEditor() {
|
||||
kcEditorModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Error saving KC target:', error);
|
||||
kcEditorModal.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneKCTarget(targetId) {
|
||||
export async function cloneKCTarget(targetId: any) {
|
||||
try {
|
||||
const targets = await outputTargetsCache.fetch();
|
||||
const target = targets.find(t => t.id === targetId);
|
||||
const target = targets.find((t: any) => t.id === targetId);
|
||||
if (!target) throw new Error('Target not found');
|
||||
showKCEditor(null, target);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('kc_target.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKCTarget(targetId) {
|
||||
export async function deleteKCTarget(targetId: any) {
|
||||
const confirmed = await showConfirm(t('kc.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -741,7 +742,7 @@ export async function deleteKCTarget(targetId) {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
@@ -749,12 +750,12 @@ export async function deleteKCTarget(targetId) {
|
||||
|
||||
// ===== KC BRIGHTNESS =====
|
||||
|
||||
export function updateKCBrightnessLabel(targetId, value) {
|
||||
const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`);
|
||||
export function updateKCBrightnessLabel(targetId: any, value: any) {
|
||||
const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`) as HTMLElement;
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
}
|
||||
|
||||
export async function saveKCBrightness(targetId, value) {
|
||||
export async function saveKCBrightness(targetId: any, value: any) {
|
||||
const brightness = parseInt(value) / 255;
|
||||
try {
|
||||
await fetch(`${API_BASE}/output-targets/${targetId}`, {
|
||||
@@ -770,7 +771,7 @@ export async function saveKCBrightness(targetId, value) {
|
||||
|
||||
// ===== KEY COLORS WEBSOCKET =====
|
||||
|
||||
export function connectKCWebSocket(targetId) {
|
||||
export function connectKCWebSocket(targetId: any) {
|
||||
// Disconnect existing connection if any
|
||||
disconnectKCWebSocket(targetId);
|
||||
|
||||
@@ -806,7 +807,7 @@ export function connectKCWebSocket(targetId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectKCWebSocket(targetId) {
|
||||
export function disconnectKCWebSocket(targetId: any) {
|
||||
const ws = kcWebSockets[targetId];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
@@ -818,7 +819,7 @@ export function disconnectAllKCWebSockets() {
|
||||
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
|
||||
}
|
||||
|
||||
export function updateKCColorSwatches(targetId, colors) {
|
||||
export function updateKCColorSwatches(targetId: any, colors: any) {
|
||||
const container = document.getElementById(`kc-swatches-${targetId}`);
|
||||
if (!container) return;
|
||||
|
||||
@@ -828,7 +829,7 @@ export function updateKCColorSwatches(targetId, colors) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map(([name, color]) => {
|
||||
container.innerHTML = entries.map(([name, color]: [string, any]) => {
|
||||
const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
|
||||
return `
|
||||
<div class="kc-swatch">
|
||||
@@ -14,19 +14,20 @@ import {
|
||||
PATTERN_RECT_COLORS,
|
||||
PATTERN_RECT_BORDERS,
|
||||
streamsCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { patternTemplatesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { patternTemplatesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import type { PatternTemplate } from '../types.ts';
|
||||
|
||||
let _patternBgEntitySelect = null;
|
||||
let _patternTagsInput = null;
|
||||
let _patternBgEntitySelect: EntitySelect | null = null;
|
||||
let _patternTagsInput: TagInput | null = null;
|
||||
|
||||
class PatternTemplateModal extends Modal {
|
||||
constructor() {
|
||||
@@ -35,8 +36,8 @@ class PatternTemplateModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('pattern-template-name').value,
|
||||
description: document.getElementById('pattern-template-description').value,
|
||||
name: (document.getElementById('pattern-template-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('pattern-template-description') as HTMLInputElement).value,
|
||||
rectangles: JSON.stringify(patternEditorRects),
|
||||
tags: JSON.stringify(_patternTagsInput ? _patternTagsInput.getValue() : []),
|
||||
};
|
||||
@@ -48,7 +49,7 @@ class PatternTemplateModal extends Modal {
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternEditorBgImage(null);
|
||||
// Clean up ResizeObserver to prevent leaks
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
const canvas = document.getElementById('pattern-canvas') as any;
|
||||
if (canvas?._patternResizeObserver) {
|
||||
canvas._patternResizeObserver.disconnect();
|
||||
canvas._patternResizeObserver = null;
|
||||
@@ -59,7 +60,7 @@ class PatternTemplateModal extends Modal {
|
||||
|
||||
const patternModal = new PatternTemplateModal();
|
||||
|
||||
export function createPatternTemplateCard(pt) {
|
||||
export function createPatternTemplateCard(pt: PatternTemplate) {
|
||||
const rectCount = (pt.rectangles || []).length;
|
||||
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
|
||||
return wrapCard({
|
||||
@@ -83,14 +84,14 @@ export function createPatternTemplateCard(pt) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function showPatternTemplateEditor(templateId = null, cloneData = null) {
|
||||
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
|
||||
try {
|
||||
// Load sources for background capture
|
||||
const sources = await streamsCache.fetch().catch(() => []);
|
||||
|
||||
const bgSelect = document.getElementById('pattern-bg-source');
|
||||
const bgSelect = document.getElementById('pattern-bg-source') as HTMLSelectElement;
|
||||
bgSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
sources.forEach((s: any) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name;
|
||||
@@ -102,7 +103,7 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
if (sources.length > 0) {
|
||||
_patternBgEntitySelect = new EntitySelect({
|
||||
target: bgSelect,
|
||||
getItems: () => sources.map(s => ({
|
||||
getItems: () => sources.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getPictureSourceIcon(s.stream_type),
|
||||
@@ -122,24 +123,24 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
const tmpl = await resp.json();
|
||||
|
||||
document.getElementById('pattern-template-id').value = tmpl.id;
|
||||
document.getElementById('pattern-template-name').value = tmpl.name;
|
||||
document.getElementById('pattern-template-description').value = tmpl.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
|
||||
(document.getElementById('pattern-template-id') as HTMLInputElement).value = tmpl.id;
|
||||
(document.getElementById('pattern-template-name') as HTMLInputElement).value = tmpl.name;
|
||||
(document.getElementById('pattern-template-description') as HTMLInputElement).value = tmpl.description || '';
|
||||
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
|
||||
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = tmpl.tags || [];
|
||||
} else if (cloneData) {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('pattern-template-description').value = cloneData.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('pattern-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
(document.getElementById('pattern-template-description') as HTMLInputElement).value = cloneData.description || '';
|
||||
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = '';
|
||||
document.getElementById('pattern-template-description').value = '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('pattern-template-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('pattern-template-description') as HTMLInputElement).value = '';
|
||||
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
setPatternEditorRects([]);
|
||||
}
|
||||
|
||||
@@ -156,7 +157,7 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
|
||||
patternModal.open();
|
||||
|
||||
document.getElementById('pattern-template-error').style.display = 'none';
|
||||
(document.getElementById('pattern-template-error') as HTMLElement).style.display = 'none';
|
||||
setTimeout(() => desktopFocus(document.getElementById('pattern-template-name')), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to open pattern template editor:', error);
|
||||
@@ -164,22 +165,22 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
}
|
||||
}
|
||||
|
||||
export function isPatternEditorDirty() {
|
||||
export function isPatternEditorDirty(): boolean {
|
||||
return patternModal.isDirty();
|
||||
}
|
||||
|
||||
export async function closePatternTemplateModal() {
|
||||
export async function closePatternTemplateModal(): Promise<void> {
|
||||
await patternModal.close();
|
||||
}
|
||||
|
||||
export function forceClosePatternTemplateModal() {
|
||||
export function forceClosePatternTemplateModal(): void {
|
||||
patternModal.forceClose();
|
||||
}
|
||||
|
||||
export async function savePatternTemplate() {
|
||||
const templateId = document.getElementById('pattern-template-id').value;
|
||||
const name = document.getElementById('pattern-template-name').value.trim();
|
||||
const description = document.getElementById('pattern-template-description').value.trim();
|
||||
export async function savePatternTemplate(): Promise<void> {
|
||||
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!name) {
|
||||
patternModal.showError(t('pattern.error.required'));
|
||||
@@ -224,10 +225,10 @@ export async function savePatternTemplate() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function clonePatternTemplate(templateId) {
|
||||
export async function clonePatternTemplate(templateId: string): Promise<void> {
|
||||
try {
|
||||
const templates = await patternTemplatesCache.fetch();
|
||||
const tmpl = templates.find(t => t.id === templateId);
|
||||
const tmpl = templates.find((t: any) => t.id === templateId);
|
||||
if (!tmpl) throw new Error('Pattern template not found');
|
||||
showPatternTemplateEditor(null, tmpl);
|
||||
} catch (error) {
|
||||
@@ -236,7 +237,7 @@ export async function clonePatternTemplate(templateId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePatternTemplate(templateId) {
|
||||
export async function deletePatternTemplate(templateId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('pattern.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -260,7 +261,7 @@ export async function deletePatternTemplate(templateId) {
|
||||
|
||||
// ----- Pattern rect list (precise coordinate inputs) -----
|
||||
|
||||
export function renderPatternRectList() {
|
||||
export function renderPatternRectList(): void {
|
||||
const container = document.getElementById('pattern-rect-list');
|
||||
if (!container) return;
|
||||
|
||||
@@ -281,13 +282,13 @@ export function renderPatternRectList() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
export function selectPatternRect(index) {
|
||||
export function selectPatternRect(index: number): void {
|
||||
setPatternEditorSelectedIdx(patternEditorSelectedIdx === index ? -1 : index);
|
||||
renderPatternRectList();
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
export function updatePatternRect(index, field, value) {
|
||||
export function updatePatternRect(index: number, field: string, value: string | number): void {
|
||||
if (index < 0 || index >= patternEditorRects.length) return;
|
||||
patternEditorRects[index][field] = value;
|
||||
// Clamp coordinates
|
||||
@@ -301,7 +302,7 @@ export function updatePatternRect(index, field, value) {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
export function addPatternRect() {
|
||||
export function addPatternRect(): void {
|
||||
const name = `Zone ${patternEditorRects.length + 1}`;
|
||||
// Inherit size from selected rect, or default to 30%
|
||||
let w = 0.3, h = 0.3;
|
||||
@@ -318,7 +319,7 @@ export function addPatternRect() {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
export function deleteSelectedPatternRect() {
|
||||
export function deleteSelectedPatternRect(): void {
|
||||
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
|
||||
patternEditorRects.splice(patternEditorSelectedIdx, 1);
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
@@ -326,7 +327,7 @@ export function deleteSelectedPatternRect() {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
export function removePatternRect(index) {
|
||||
export function removePatternRect(index: number): void {
|
||||
patternEditorRects.splice(index, 1);
|
||||
if (patternEditorSelectedIdx === index) setPatternEditorSelectedIdx(-1);
|
||||
else if (patternEditorSelectedIdx > index) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
|
||||
@@ -336,8 +337,8 @@ export function removePatternRect(index) {
|
||||
|
||||
// ----- Pattern Canvas Visual Editor -----
|
||||
|
||||
export function renderPatternCanvas() {
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
export function renderPatternCanvas(): void {
|
||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
@@ -490,7 +491,7 @@ const _ADD_BTN_ANCHORS = [
|
||||
{ ax: 1, ay: 1 },
|
||||
];
|
||||
|
||||
function _hitTestAddButtons(mx, my, w, h) {
|
||||
function _hitTestAddButtons(mx: number, my: number, w: number, h: number): number {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const abR = 12 * dpr;
|
||||
const abMargin = 18 * dpr;
|
||||
@@ -508,7 +509,7 @@ function _hitTestAddButtons(mx, my, w, h) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
function _addRectAtAnchor(anchorIdx) {
|
||||
function _addRectAtAnchor(anchorIdx: number): void {
|
||||
const anchor = _ADD_BTN_ANCHORS[anchorIdx];
|
||||
const name = `Zone ${patternEditorRects.length + 1}`;
|
||||
let rw = 0.3, rh = 0.3;
|
||||
@@ -530,7 +531,7 @@ function _addRectAtAnchor(anchorIdx) {
|
||||
// Hit-test a point against a rect's edges/corners.
|
||||
const _EDGE_THRESHOLD = 8;
|
||||
|
||||
function _hitTestRect(mx, my, r, w, h) {
|
||||
function _hitTestRect(mx: number, my: number, r: { x: number; y: number; width: number; height: number }, w: number, h: number): string | null {
|
||||
const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const thr = _EDGE_THRESHOLD * dpr;
|
||||
@@ -564,7 +565,7 @@ const _DIR_CURSORS = {
|
||||
'move': 'grab',
|
||||
};
|
||||
|
||||
function _hitTestDeleteButton(mx, my, rect, w, h) {
|
||||
function _hitTestDeleteButton(mx: number, my: number, rect: { x: number; y: number; width: number; height: number }, w: number, h: number): boolean {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const btnR = 9 * dpr;
|
||||
const rx = rect.x * w, ry = rect.y * h, rw = rect.width * w;
|
||||
@@ -574,9 +575,9 @@ function _hitTestDeleteButton(mx, my, rect, w, h) {
|
||||
return (dx * dx + dy * dy) <= (btnR + 2 * dpr) * (btnR + 2 * dpr);
|
||||
}
|
||||
|
||||
function _patternCanvasDragMove(e) {
|
||||
function _patternCanvasDragMove(e: MouseEvent | { clientX: number; clientY: number }): void {
|
||||
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
@@ -611,7 +612,7 @@ function _patternCanvasDragMove(e) {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _patternCanvasDragEnd(e) {
|
||||
function _patternCanvasDragEnd(e: MouseEvent): void {
|
||||
window.removeEventListener('mousemove', _patternCanvasDragMove);
|
||||
window.removeEventListener('mouseup', _patternCanvasDragEnd);
|
||||
setPatternCanvasDragMode(null);
|
||||
@@ -619,7 +620,7 @@ function _patternCanvasDragEnd(e) {
|
||||
setPatternCanvasDragOrigRect(null);
|
||||
|
||||
// Recalculate hover at current mouse position
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
@@ -651,8 +652,8 @@ function _patternCanvasDragEnd(e) {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _attachPatternCanvasEvents() {
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
function _attachPatternCanvasEvents(): void {
|
||||
const canvas = document.getElementById('pattern-canvas') as any;
|
||||
if (!canvas || canvas._patternEventsAttached) return;
|
||||
canvas._patternEventsAttached = true;
|
||||
|
||||
@@ -704,13 +705,13 @@ function _attachPatternCanvasEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
function _touchToMouseEvent(canvas, touch, type) {
|
||||
function _touchToMouseEvent(canvas: HTMLCanvasElement, touch: Touch, type: string): { type: string; offsetX: number; offsetY: number; preventDefault: () => void } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} };
|
||||
}
|
||||
|
||||
function _patternCanvasMouseDown(e) {
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: number; clientX?: number; clientY?: number; preventDefault: () => void }): void {
|
||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -785,10 +786,10 @@ function _patternCanvasMouseDown(e) {
|
||||
renderPatternCanvas();
|
||||
}
|
||||
|
||||
function _patternCanvasMouseMove(e) {
|
||||
function _patternCanvasMouseMove(e: MouseEvent | { offsetX?: number; offsetY?: number; clientX?: number; clientY?: number }): void {
|
||||
if (patternCanvasDragMode) return;
|
||||
|
||||
const canvas = document.getElementById('pattern-canvas');
|
||||
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -824,7 +825,7 @@ function _patternCanvasMouseMove(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function _patternCanvasMouseLeave() {
|
||||
function _patternCanvasMouseLeave(): void {
|
||||
if (patternCanvasDragMode) return;
|
||||
if (patternEditorHoveredIdx !== -1) {
|
||||
setPatternEditorHoveredIdx(-1);
|
||||
@@ -833,8 +834,8 @@ function _patternCanvasMouseLeave() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function capturePatternBackground() {
|
||||
const sourceId = document.getElementById('pattern-bg-source').value;
|
||||
export async function capturePatternBackground(): Promise<void> {
|
||||
const sourceId = (document.getElementById('pattern-bg-source') as HTMLSelectElement).value;
|
||||
if (!sourceId) {
|
||||
showToast(t('pattern.source_for_bg.none'), 'error');
|
||||
return;
|
||||
@@ -7,26 +7,26 @@ import { Chart, registerables } from 'chart.js';
|
||||
Chart.register(...registerables);
|
||||
window.Chart = Chart; // expose globally for targets.js, dashboard.js
|
||||
|
||||
import { API_BASE, getHeaders } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { dashboardPollInterval } from '../core/state.js';
|
||||
import { createColorPicker, registerColorPicker } from '../core/color-picker.js';
|
||||
import { API_BASE, getHeaders } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { dashboardPollInterval } from '../core/state.ts';
|
||||
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
|
||||
|
||||
const MAX_SAMPLES = 120;
|
||||
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
|
||||
|
||||
let _pollTimer = null;
|
||||
let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
|
||||
let _history = { cpu: [], ram: [], gpu: [] };
|
||||
let _hasGpu = null; // null = unknown, true/false after first fetch
|
||||
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
|
||||
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
|
||||
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
|
||||
|
||||
function _getColor(key) {
|
||||
function _getColor(key: string): string {
|
||||
return localStorage.getItem(`perfChartColor_${key}`)
|
||||
|| getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|
||||
|| '#4CAF50';
|
||||
}
|
||||
|
||||
function _onChartColorChange(key, hex) {
|
||||
function _onChartColorChange(key: string, hex: string | null): void {
|
||||
if (hex) {
|
||||
localStorage.setItem(`perfChartColor_${key}`, hex);
|
||||
} else {
|
||||
@@ -46,7 +46,7 @@ function _onChartColorChange(key, hex) {
|
||||
}
|
||||
|
||||
/** Returns the static HTML for the perf section (canvas placeholders). */
|
||||
export function renderPerfSection() {
|
||||
export function renderPerfSection(): string {
|
||||
// Register callbacks before rendering
|
||||
for (const key of CHART_KEYS) {
|
||||
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
|
||||
@@ -55,21 +55,21 @@ export function renderPerfSection() {
|
||||
return `<div class="perf-charts-grid">
|
||||
<div class="perf-chart-card">
|
||||
<div class="perf-chart-header">
|
||||
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: null, anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-value" id="perf-cpu-value">-</span>
|
||||
</div>
|
||||
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
|
||||
</div>
|
||||
<div class="perf-chart-card">
|
||||
<div class="perf-chart-header">
|
||||
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: null, anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-value" id="perf-ram-value">-</span>
|
||||
</div>
|
||||
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
|
||||
</div>
|
||||
<div class="perf-chart-card" id="perf-gpu-card">
|
||||
<div class="perf-chart-header">
|
||||
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: null, anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-value" id="perf-gpu-value">-</span>
|
||||
</div>
|
||||
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>
|
||||
@@ -77,8 +77,8 @@ export function renderPerfSection() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _createChart(canvasId, key) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
function _createChart(canvasId: string, key: string): any {
|
||||
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
|
||||
if (!ctx) return null;
|
||||
const color = _getColor(key);
|
||||
return new Chart(ctx, {
|
||||
@@ -110,7 +110,7 @@ function _createChart(canvasId, key) {
|
||||
}
|
||||
|
||||
/** Seed charts from server-side metrics history. */
|
||||
async function _seedFromServer() {
|
||||
async function _seedFromServer(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
|
||||
if (!resp.ok) return;
|
||||
@@ -138,7 +138,7 @@ async function _seedFromServer() {
|
||||
}
|
||||
|
||||
/** Initialize Chart.js instances on the already-mounted canvases. */
|
||||
export async function initPerfCharts() {
|
||||
export async function initPerfCharts(): Promise<void> {
|
||||
_destroyCharts();
|
||||
_charts.cpu = _createChart('perf-chart-cpu', 'cpu');
|
||||
_charts.ram = _createChart('perf-chart-ram', 'ram');
|
||||
@@ -146,13 +146,13 @@ export async function initPerfCharts() {
|
||||
await _seedFromServer();
|
||||
}
|
||||
|
||||
function _destroyCharts() {
|
||||
function _destroyCharts(): void {
|
||||
for (const key of Object.keys(_charts)) {
|
||||
if (_charts[key]) { _charts[key].destroy(); _charts[key] = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function _pushSample(key, value) {
|
||||
function _pushSample(key: string, value: number): void {
|
||||
_history[key].push(value);
|
||||
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
|
||||
const chart = _charts[key];
|
||||
@@ -166,7 +166,7 @@ function _pushSample(key, value) {
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
async function _fetchPerformance() {
|
||||
async function _fetchPerformance(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
|
||||
if (!resp.ok) return;
|
||||
@@ -217,13 +217,13 @@ async function _fetchPerformance() {
|
||||
}
|
||||
}
|
||||
|
||||
export function startPerfPolling() {
|
||||
export function startPerfPolling(): void {
|
||||
if (_pollTimer) return;
|
||||
_fetchPerformance();
|
||||
_pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
|
||||
}
|
||||
|
||||
export function stopPerfPolling() {
|
||||
export function stopPerfPolling(): void {
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
@@ -3,22 +3,23 @@
|
||||
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_TRASH,
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
} from '../core/icons.ts';
|
||||
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
|
||||
import { EntityPalette } from '../core/entity-palette.ts';
|
||||
import type { ScenePreset } from '../types.ts';
|
||||
|
||||
let _editingId = null;
|
||||
let _editingId: string | null = null;
|
||||
let _allTargets = []; // fetched on capture open
|
||||
let _sceneTagsInput = null;
|
||||
let _sceneTagsInput: TagInput | null = null;
|
||||
|
||||
class ScenePresetEditorModal extends Modal {
|
||||
constructor() { super('scene-preset-editor-modal'); }
|
||||
@@ -27,10 +28,10 @@ class ScenePresetEditorModal extends Modal {
|
||||
}
|
||||
snapshotValues() {
|
||||
const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId).sort().join(',');
|
||||
.map(el => (el as HTMLElement).dataset.targetId).sort().join(',');
|
||||
return {
|
||||
name: document.getElementById('scene-preset-editor-name').value,
|
||||
description: document.getElementById('scene-preset-editor-description').value,
|
||||
name: (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value,
|
||||
targets: items,
|
||||
tags: JSON.stringify(_sceneTagsInput ? _sceneTagsInput.getValue() : []),
|
||||
};
|
||||
@@ -59,7 +60,7 @@ export const csScenes = new CardSection('scenes', {
|
||||
}],
|
||||
});
|
||||
|
||||
export function createSceneCard(preset) {
|
||||
export function createSceneCard(preset: ScenePreset) {
|
||||
const targetCount = (preset.targets || []).length;
|
||||
|
||||
const automations = automationsCacheObj.data || [];
|
||||
@@ -98,11 +99,11 @@ export function createSceneCard(preset) {
|
||||
|
||||
// ===== Dashboard section (compact cards) =====
|
||||
|
||||
export async function loadScenePresets() {
|
||||
export async function loadScenePresets(): Promise<ScenePreset[]> {
|
||||
return scenePresetsCache.fetch();
|
||||
}
|
||||
|
||||
export function renderScenePresetsSection(presets) {
|
||||
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
|
||||
if (!presets || presets.length === 0) return '';
|
||||
|
||||
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
|
||||
@@ -111,7 +112,7 @@ export function renderScenePresetsSection(presets) {
|
||||
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
||||
}
|
||||
|
||||
function _renderDashboardPresetCard(preset) {
|
||||
function _renderDashboardPresetCard(preset: ScenePreset): string {
|
||||
const targetCount = (preset.targets || []).length;
|
||||
|
||||
const subtitle = [
|
||||
@@ -136,13 +137,13 @@ function _renderDashboardPresetCard(preset) {
|
||||
|
||||
// ===== Capture (create) =====
|
||||
|
||||
export async function openScenePresetCapture() {
|
||||
export async function openScenePresetCapture(): Promise<void> {
|
||||
_editingId = null;
|
||||
document.getElementById('scene-preset-editor-id').value = '';
|
||||
document.getElementById('scene-preset-editor-name').value = '';
|
||||
document.getElementById('scene-preset-editor-description').value = '';
|
||||
(document.getElementById('scene-preset-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('scene-preset-editor-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('scene-preset-editor-description') as HTMLInputElement).value = '';
|
||||
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
(document.getElementById('scene-preset-editor-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
|
||||
@@ -169,15 +170,15 @@ export async function openScenePresetCapture() {
|
||||
|
||||
// ===== Edit metadata =====
|
||||
|
||||
export async function editScenePreset(presetId) {
|
||||
export async function editScenePreset(presetId: string): Promise<void> {
|
||||
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
_editingId = presetId;
|
||||
document.getElementById('scene-preset-editor-id').value = presetId;
|
||||
document.getElementById('scene-preset-editor-name').value = preset.name;
|
||||
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
(document.getElementById('scene-preset-editor-id') as HTMLInputElement).value = presetId;
|
||||
(document.getElementById('scene-preset-editor-name') as HTMLInputElement).value = preset.name;
|
||||
(document.getElementById('scene-preset-editor-description') as HTMLInputElement).value = preset.description || '';
|
||||
(document.getElementById('scene-preset-editor-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
|
||||
@@ -216,9 +217,9 @@ export async function editScenePreset(presetId) {
|
||||
|
||||
// ===== Save (create or update) =====
|
||||
|
||||
export async function saveScenePreset() {
|
||||
const name = document.getElementById('scene-preset-editor-name').value.trim();
|
||||
const description = document.getElementById('scene-preset-editor-description').value.trim();
|
||||
export async function saveScenePreset(): Promise<void> {
|
||||
const name = (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('scene-preset-editor-error');
|
||||
|
||||
if (!name) {
|
||||
@@ -233,14 +234,14 @@ export async function saveScenePreset() {
|
||||
let resp;
|
||||
if (_editingId) {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId);
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId);
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
@@ -265,29 +266,29 @@ export async function saveScenePreset() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeScenePresetEditor() {
|
||||
export async function closeScenePresetEditor(): Promise<void> {
|
||||
await scenePresetModal.close();
|
||||
}
|
||||
|
||||
// ===== Target selector helpers =====
|
||||
|
||||
function _getAddedTargetIds() {
|
||||
function _getAddedTargetIds(): Set<string> {
|
||||
return new Set(
|
||||
[...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId)
|
||||
.map(el => (el as HTMLElement).dataset.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
function _refreshTargetSelect() {
|
||||
function _refreshTargetSelect(): void {
|
||||
// Update add button disabled state
|
||||
const addBtn = document.getElementById('scene-target-add-btn');
|
||||
const addBtn = document.getElementById('scene-target-add-btn') as HTMLButtonElement | null;
|
||||
if (addBtn) {
|
||||
const added = _getAddedTargetIds();
|
||||
addBtn.disabled = _allTargets.every(t => added.has(t.id));
|
||||
}
|
||||
}
|
||||
|
||||
function _addTargetToList(targetId, targetName) {
|
||||
function _addTargetToList(targetId: string, targetName: string): void {
|
||||
const list = document.getElementById('scene-target-list');
|
||||
if (!list) return;
|
||||
const item = document.createElement('div');
|
||||
@@ -298,7 +299,7 @@ function _addTargetToList(targetId, targetName) {
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
|
||||
export async function addSceneTarget() {
|
||||
export async function addSceneTarget(): Promise<void> {
|
||||
const added = _getAddedTargetIds();
|
||||
const available = _allTargets.filter(t => !added.has(t.id));
|
||||
if (available.length === 0) return;
|
||||
@@ -319,14 +320,14 @@ export async function addSceneTarget() {
|
||||
if (tgt) _addTargetToList(tgt.id, tgt.name);
|
||||
}
|
||||
|
||||
export function removeSceneTarget(btn) {
|
||||
export function removeSceneTarget(btn: HTMLElement): void {
|
||||
btn.closest('.scene-target-item').remove();
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
|
||||
// ===== Activate =====
|
||||
|
||||
export async function activateScenePreset(presetId) {
|
||||
export async function activateScenePreset(presetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
||||
method: 'POST',
|
||||
@@ -345,7 +346,7 @@ export async function activateScenePreset(presetId) {
|
||||
showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning');
|
||||
}
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
@@ -353,7 +354,7 @@ export async function activateScenePreset(presetId) {
|
||||
|
||||
// ===== Recapture =====
|
||||
|
||||
export async function recaptureScenePreset(presetId) {
|
||||
export async function recaptureScenePreset(presetId: string): Promise<void> {
|
||||
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
||||
const name = preset ? preset.name : presetId;
|
||||
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
|
||||
@@ -381,16 +382,16 @@ export async function recaptureScenePreset(presetId) {
|
||||
|
||||
// ===== Clone =====
|
||||
|
||||
export async function cloneScenePreset(presetId) {
|
||||
export async function cloneScenePreset(presetId: string): Promise<void> {
|
||||
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
// Open the capture modal in create mode, prefilled from the cloned preset
|
||||
_editingId = null;
|
||||
document.getElementById('scene-preset-editor-id').value = '';
|
||||
document.getElementById('scene-preset-editor-name').value = (preset.name || '') + ' (Copy)';
|
||||
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
(document.getElementById('scene-preset-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('scene-preset-editor-name') as HTMLInputElement).value = (preset.name || '') + ' (Copy)';
|
||||
(document.getElementById('scene-preset-editor-description') as HTMLInputElement).value = preset.description || '';
|
||||
(document.getElementById('scene-preset-editor-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
|
||||
@@ -429,7 +430,7 @@ export async function cloneScenePreset(presetId) {
|
||||
|
||||
// ===== Delete =====
|
||||
|
||||
export async function deleteScenePreset(presetId) {
|
||||
export async function deleteScenePreset(presetId: string): Promise<void> {
|
||||
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
||||
const name = preset ? preset.name : presetId;
|
||||
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
|
||||
@@ -457,7 +458,7 @@ export async function deleteScenePreset(presetId) {
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
function _reloadScenesTab() {
|
||||
function _reloadScenesTab(): void {
|
||||
// Reload automations tab (which includes scenes section)
|
||||
if ((localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
|
||||
if (typeof window.loadAutomations === 'function') window.loadAutomations();
|
||||
@@ -2,20 +2,20 @@
|
||||
* Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
|
||||
*/
|
||||
|
||||
import { apiKey } from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { apiKey } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
|
||||
// ─── External URL (used by other modules for user-visible URLs) ──
|
||||
|
||||
let _externalUrl = '';
|
||||
|
||||
/** Get the configured external base URL (empty string = not set). */
|
||||
export function getExternalUrl() {
|
||||
export function getExternalUrl(): string {
|
||||
return _externalUrl;
|
||||
}
|
||||
|
||||
@@ -23,25 +23,25 @@ export function getExternalUrl() {
|
||||
* Return the base origin for user-visible URLs (webhook, WS).
|
||||
* If an external URL is configured, use that; otherwise fall back to window.location.origin.
|
||||
*/
|
||||
export function getBaseOrigin() {
|
||||
export function getBaseOrigin(): string {
|
||||
return _externalUrl || window.location.origin;
|
||||
}
|
||||
|
||||
export async function loadExternalUrl() {
|
||||
export async function loadExternalUrl(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/external-url');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
_externalUrl = data.external_url || '';
|
||||
const input = document.getElementById('settings-external-url');
|
||||
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
|
||||
if (input) input.value = _externalUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveExternalUrl() {
|
||||
const input = document.getElementById('settings-external-url');
|
||||
export async function saveExternalUrl(): Promise<void> {
|
||||
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
const url = input.value.trim().replace(/\/+$/, '');
|
||||
try {
|
||||
@@ -65,9 +65,9 @@ export async function saveExternalUrl() {
|
||||
|
||||
// ─── Settings-modal tab switching ───────────────────────────
|
||||
|
||||
export function switchSettingsTab(tabId) {
|
||||
export function switchSettingsTab(tabId: string): void {
|
||||
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.settingsTab === tabId);
|
||||
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
|
||||
});
|
||||
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
||||
@@ -77,38 +77,38 @@ export function switchSettingsTab(tabId) {
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let _logWs = null;
|
||||
let _logWs: WebSocket | null = null;
|
||||
|
||||
/** Level ordering for filter comparisons */
|
||||
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
|
||||
|
||||
function _detectLevel(line) {
|
||||
function _detectLevel(line: string): string {
|
||||
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
|
||||
if (line.includes(lvl)) return lvl;
|
||||
}
|
||||
return 'DEBUG';
|
||||
}
|
||||
|
||||
function _levelClass(level) {
|
||||
function _levelClass(level: string): string {
|
||||
if (level === 'ERROR' || level === 'CRITICAL') return 'log-line-error';
|
||||
if (level === 'WARNING') return 'log-line-warning';
|
||||
if (level === 'DEBUG') return 'log-line-debug';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _filterLevel() {
|
||||
const sel = document.getElementById('log-viewer-filter');
|
||||
function _filterLevel(): string {
|
||||
const sel = document.getElementById('log-viewer-filter') as HTMLSelectElement | null;
|
||||
return sel ? sel.value : 'all';
|
||||
}
|
||||
|
||||
function _linePassesFilter(line) {
|
||||
function _linePassesFilter(line: string): boolean {
|
||||
const filter = _filterLevel();
|
||||
if (filter === 'all') return true;
|
||||
const lineLvl = _detectLevel(line);
|
||||
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
}
|
||||
|
||||
function _appendLine(line) {
|
||||
function _appendLine(line: string): void {
|
||||
// Skip keepalive empty pings
|
||||
if (!line) return;
|
||||
if (!_linePassesFilter(line)) return;
|
||||
@@ -128,7 +128,7 @@ function _appendLine(line) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
export function connectLogViewer() {
|
||||
export function connectLogViewer(): void {
|
||||
const btn = document.getElementById('log-viewer-connect-btn');
|
||||
|
||||
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
|
||||
@@ -162,7 +162,7 @@ export function connectLogViewer() {
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectLogViewer() {
|
||||
export function disconnectLogViewer(): void {
|
||||
if (_logWs) {
|
||||
_logWs.close();
|
||||
_logWs = null;
|
||||
@@ -171,30 +171,30 @@ export function disconnectLogViewer() {
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
}
|
||||
|
||||
export function clearLogViewer() {
|
||||
export function clearLogViewer(): void {
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (output) output.innerHTML = '';
|
||||
}
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
export function applyLogFilter() {
|
||||
export function applyLogFilter(): void {
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
for (const span of output.children) {
|
||||
for (const span of Array.from(output.children)) {
|
||||
const line = span.textContent;
|
||||
const lineLvl = _detectLevel(line);
|
||||
const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
span.style.display = passes ? '' : 'none';
|
||||
(span as HTMLElement).style.display = passes ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Overlay (full-screen) ──────────────────────────────
|
||||
|
||||
let _logFilterIconSelect = null;
|
||||
let _logFilterIconSelect: IconSelect | null = null;
|
||||
|
||||
/** Build filter items lazily so t() has locale data loaded. */
|
||||
function _getLogFilterItems() {
|
||||
function _getLogFilterItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
|
||||
@@ -203,14 +203,14 @@ function _getLogFilterItems() {
|
||||
];
|
||||
}
|
||||
|
||||
export function openLogOverlay() {
|
||||
export function openLogOverlay(): void {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
// Initialize log filter icon select (once)
|
||||
if (!_logFilterIconSelect) {
|
||||
const filterSel = document.getElementById('log-viewer-filter');
|
||||
const filterSel = document.getElementById('log-viewer-filter') as HTMLSelectElement | null;
|
||||
if (filterSel) {
|
||||
_logFilterIconSelect = new IconSelect({
|
||||
target: filterSel,
|
||||
@@ -228,7 +228,7 @@ export function openLogOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
export function closeLogOverlay() {
|
||||
export function closeLogOverlay(): void {
|
||||
const overlay = document.getElementById('log-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
disconnectLogViewer();
|
||||
@@ -239,10 +239,10 @@ export function closeLogOverlay() {
|
||||
// Simple modal (no form / no dirty check needed)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
let _logLevelIconSelect = null;
|
||||
let _logLevelIconSelect: IconSelect | null = null;
|
||||
|
||||
/** Build log-level items lazily so t() has locale data loaded. */
|
||||
function _getLogLevelItems() {
|
||||
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
|
||||
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
|
||||
@@ -252,8 +252,8 @@ function _getLogLevelItems() {
|
||||
];
|
||||
}
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
export function openSettingsModal(): void {
|
||||
(document.getElementById('settings-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
// Reset to first tab
|
||||
switchSettingsTab('general');
|
||||
@@ -262,7 +262,7 @@ export function openSettingsModal() {
|
||||
|
||||
// Initialize log level icon select
|
||||
if (!_logLevelIconSelect) {
|
||||
const levelSel = document.getElementById('settings-log-level');
|
||||
const levelSel = document.getElementById('settings-log-level') as HTMLSelectElement | null;
|
||||
if (levelSel) {
|
||||
_logLevelIconSelect = new IconSelect({
|
||||
target: levelSel,
|
||||
@@ -281,13 +281,13 @@ export function openSettingsModal() {
|
||||
loadLogLevel();
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
export function closeSettingsModal(): void {
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
// ─── Backup ────────────────────────────────────────────────
|
||||
|
||||
export async function downloadBackup() {
|
||||
export async function downloadBackup(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/backup', { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
@@ -316,7 +316,7 @@ export async function downloadBackup() {
|
||||
|
||||
// ─── Restore ───────────────────────────────────────────────
|
||||
|
||||
export async function handleRestoreFileSelected(input) {
|
||||
export async function handleRestoreFileSelected(input: HTMLInputElement): Promise<void> {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
@@ -354,7 +354,7 @@ export async function handleRestoreFileSelected(input) {
|
||||
|
||||
// ─── Server restart ────────────────────────────────────────
|
||||
|
||||
export async function restartServer() {
|
||||
export async function restartServer(): Promise<void> {
|
||||
const confirmed = await showConfirm(t('settings.restart_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -374,7 +374,7 @@ export async function restartServer() {
|
||||
|
||||
// ─── Restart overlay ───────────────────────────────────────
|
||||
|
||||
function showRestartOverlay(message) {
|
||||
function showRestartOverlay(message?: string): void {
|
||||
const msg = message || t('settings.restore.restarting');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'restart-overlay';
|
||||
@@ -398,7 +398,7 @@ function showRestartOverlay(message) {
|
||||
pollHealth();
|
||||
}
|
||||
|
||||
function pollHealth() {
|
||||
function pollHealth(): void {
|
||||
const start = Date.now();
|
||||
const maxWait = 30000;
|
||||
const interval = 1500;
|
||||
@@ -424,15 +424,15 @@ function pollHealth() {
|
||||
|
||||
// ─── Auto-Backup settings ─────────────────────────────────
|
||||
|
||||
export async function loadAutoBackupSettings() {
|
||||
export async function loadAutoBackupSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/auto-backup/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('auto-backup-enabled').checked = data.enabled;
|
||||
document.getElementById('auto-backup-interval').value = String(data.interval_hours);
|
||||
document.getElementById('auto-backup-max').value = data.max_backups;
|
||||
(document.getElementById('auto-backup-enabled') as HTMLInputElement).checked = data.enabled;
|
||||
(document.getElementById('auto-backup-interval') as HTMLInputElement).value = String(data.interval_hours);
|
||||
(document.getElementById('auto-backup-max') as HTMLInputElement).value = data.max_backups;
|
||||
|
||||
const statusEl = document.getElementById('auto-backup-status');
|
||||
if (data.last_backup_time) {
|
||||
@@ -446,10 +446,10 @@ export async function loadAutoBackupSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAutoBackupSettings() {
|
||||
const enabled = document.getElementById('auto-backup-enabled').checked;
|
||||
const interval_hours = parseFloat(document.getElementById('auto-backup-interval').value);
|
||||
const max_backups = parseInt(document.getElementById('auto-backup-max').value, 10);
|
||||
export async function saveAutoBackupSettings(): Promise<void> {
|
||||
const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked;
|
||||
const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value);
|
||||
const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).value, 10);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/auto-backup/settings', {
|
||||
@@ -471,7 +471,7 @@ export async function saveAutoBackupSettings() {
|
||||
|
||||
// ─── Saved backup list ────────────────────────────────────
|
||||
|
||||
export async function loadBackupList() {
|
||||
export async function loadBackupList(): Promise<void> {
|
||||
const container = document.getElementById('saved-backups-list');
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/backups');
|
||||
@@ -510,7 +510,7 @@ export async function loadBackupList() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSavedBackup(filename) {
|
||||
export async function downloadSavedBackup(filename: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/backups/${encodeURIComponent(filename)}`, { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
@@ -531,7 +531,7 @@ export async function downloadSavedBackup(filename) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSavedBackup(filename) {
|
||||
export async function restoreSavedBackup(filename: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('settings.restore.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -572,7 +572,7 @@ export async function restoreSavedBackup(filename) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSavedBackup(filename) {
|
||||
export async function deleteSavedBackup(filename: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('settings.saved_backups.delete_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -593,7 +593,7 @@ export async function deleteSavedBackup(filename) {
|
||||
|
||||
// ─── API Keys (read-only display) ─────────────────────────────
|
||||
|
||||
export async function loadApiKeysList() {
|
||||
export async function loadApiKeysList(): Promise<void> {
|
||||
const container = document.getElementById('settings-api-keys-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
@@ -621,8 +621,8 @@ export async function loadApiKeysList() {
|
||||
|
||||
// ─── Partial Export / Import ───────────────────────────────────
|
||||
|
||||
export async function downloadPartialExport() {
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
export async function downloadPartialExport(): Promise<void> {
|
||||
const storeKey = (document.getElementById('settings-partial-store') as HTMLSelectElement).value;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/export/${encodeURIComponent(storeKey)}`, { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
@@ -649,13 +649,13 @@ export async function downloadPartialExport() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePartialImportFileSelected(input) {
|
||||
export async function handlePartialImportFileSelected(input: HTMLInputElement): Promise<void> {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
const merge = document.getElementById('settings-partial-merge').checked;
|
||||
const storeKey = (document.getElementById('settings-partial-store') as HTMLSelectElement).value;
|
||||
const merge = (document.getElementById('settings-partial-merge') as HTMLInputElement).checked;
|
||||
const confirmMsg = merge
|
||||
? t('settings.partial.import_confirm_merge').replace('{store}', storeKey)
|
||||
: t('settings.partial.import_confirm_replace').replace('{store}', storeKey);
|
||||
@@ -694,7 +694,7 @@ export async function handlePartialImportFileSelected(input) {
|
||||
|
||||
// ─── Log Level ────────────────────────────────────────────────
|
||||
|
||||
export async function loadLogLevel() {
|
||||
export async function loadLogLevel(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/log-level');
|
||||
if (!resp.ok) return;
|
||||
@@ -702,7 +702,7 @@ export async function loadLogLevel() {
|
||||
if (_logLevelIconSelect) {
|
||||
_logLevelIconSelect.setValue(data.level);
|
||||
} else {
|
||||
const select = document.getElementById('settings-log-level');
|
||||
const select = document.getElementById('settings-log-level') as HTMLSelectElement | null;
|
||||
if (select) select.value = data.level;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -710,8 +710,8 @@ export async function loadLogLevel() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogLevel() {
|
||||
const select = document.getElementById('settings-log-level');
|
||||
export async function setLogLevel(): Promise<void> {
|
||||
const select = document.getElementById('settings-log-level') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const level = select.value;
|
||||
try {
|
||||
@@ -732,19 +732,19 @@ export async function setLogLevel() {
|
||||
|
||||
// ─── MQTT settings ────────────────────────────────────────────
|
||||
|
||||
export async function loadMqttSettings() {
|
||||
export async function loadMqttSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('mqtt-enabled').checked = data.enabled;
|
||||
document.getElementById('mqtt-host').value = data.broker_host;
|
||||
document.getElementById('mqtt-port').value = data.broker_port;
|
||||
document.getElementById('mqtt-username').value = data.username;
|
||||
document.getElementById('mqtt-password').value = '';
|
||||
document.getElementById('mqtt-client-id').value = data.client_id;
|
||||
document.getElementById('mqtt-base-topic').value = data.base_topic;
|
||||
(document.getElementById('mqtt-enabled') as HTMLInputElement).checked = data.enabled;
|
||||
(document.getElementById('mqtt-host') as HTMLInputElement).value = data.broker_host;
|
||||
(document.getElementById('mqtt-port') as HTMLInputElement).value = data.broker_port;
|
||||
(document.getElementById('mqtt-username') as HTMLInputElement).value = data.username;
|
||||
(document.getElementById('mqtt-password') as HTMLInputElement).value = '';
|
||||
(document.getElementById('mqtt-client-id') as HTMLInputElement).value = data.client_id;
|
||||
(document.getElementById('mqtt-base-topic') as HTMLInputElement).value = data.base_topic;
|
||||
|
||||
const hint = document.getElementById('mqtt-password-hint');
|
||||
if (hint) hint.style.display = data.password_set ? '' : 'none';
|
||||
@@ -753,14 +753,14 @@ export async function loadMqttSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMqttSettings() {
|
||||
const enabled = document.getElementById('mqtt-enabled').checked;
|
||||
const broker_host = document.getElementById('mqtt-host').value.trim();
|
||||
const broker_port = parseInt(document.getElementById('mqtt-port').value, 10);
|
||||
const username = document.getElementById('mqtt-username').value;
|
||||
const password = document.getElementById('mqtt-password').value;
|
||||
const client_id = document.getElementById('mqtt-client-id').value.trim();
|
||||
const base_topic = document.getElementById('mqtt-base-topic').value.trim();
|
||||
export async function saveMqttSettings(): Promise<void> {
|
||||
const enabled = (document.getElementById('mqtt-enabled') as HTMLInputElement).checked;
|
||||
const broker_host = (document.getElementById('mqtt-host') as HTMLInputElement).value.trim();
|
||||
const broker_port = parseInt((document.getElementById('mqtt-port') as HTMLInputElement).value, 10);
|
||||
const username = (document.getElementById('mqtt-username') as HTMLInputElement).value;
|
||||
const password = (document.getElementById('mqtt-password') as HTMLInputElement).value;
|
||||
const client_id = (document.getElementById('mqtt-client-id') as HTMLInputElement).value.trim();
|
||||
const base_topic = (document.getElementById('mqtt-base-topic') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!broker_host) {
|
||||
showToast(t('settings.mqtt.error_host_required'), 'error');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,20 @@
|
||||
* Sync Clocks — CRUD, runtime controls, cards.
|
||||
*/
|
||||
|
||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { SyncClock } from '../types.ts';
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _syncClockTagsInput = null;
|
||||
let _syncClockTagsInput: TagInput | null = null;
|
||||
|
||||
class SyncClockModal extends Modal {
|
||||
constructor() { super('sync-clock-modal'); }
|
||||
@@ -25,9 +26,9 @@ class SyncClockModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('sync-clock-name').value,
|
||||
speed: document.getElementById('sync-clock-speed').value,
|
||||
description: document.getElementById('sync-clock-description').value,
|
||||
name: (document.getElementById('sync-clock-name') as HTMLInputElement).value,
|
||||
speed: (document.getElementById('sync-clock-speed') as HTMLInputElement).value,
|
||||
description: (document.getElementById('sync-clock-description') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_syncClockTagsInput ? _syncClockTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -37,23 +38,23 @@ const syncClockModal = new SyncClockModal();
|
||||
|
||||
// ── Show / Close ──
|
||||
|
||||
export async function showSyncClockModal(editData) {
|
||||
export async function showSyncClockModal(editData: SyncClock | null): Promise<void> {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'sync_clock.edit' : 'sync_clock.add';
|
||||
document.getElementById('sync-clock-modal-title').innerHTML = `${ICON_CLOCK} ${t(titleKey)}`;
|
||||
document.getElementById('sync-clock-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('sync-clock-error').style.display = 'none';
|
||||
(document.getElementById('sync-clock-id') as HTMLInputElement).value = isEdit ? editData.id : '';
|
||||
(document.getElementById('sync-clock-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('sync-clock-name').value = editData.name || '';
|
||||
document.getElementById('sync-clock-speed').value = editData.speed ?? 1.0;
|
||||
document.getElementById('sync-clock-speed-display').textContent = editData.speed ?? 1.0;
|
||||
document.getElementById('sync-clock-description').value = editData.description || '';
|
||||
(document.getElementById('sync-clock-name') as HTMLInputElement).value = editData.name || '';
|
||||
(document.getElementById('sync-clock-speed') as HTMLInputElement).value = String(editData.speed ?? 1.0);
|
||||
document.getElementById('sync-clock-speed-display').textContent = String(editData.speed ?? 1.0);
|
||||
(document.getElementById('sync-clock-description') as HTMLInputElement).value = editData.description || '';
|
||||
} else {
|
||||
document.getElementById('sync-clock-name').value = '';
|
||||
document.getElementById('sync-clock-speed').value = 1.0;
|
||||
(document.getElementById('sync-clock-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('sync-clock-speed') as HTMLInputElement).value = String(1.0);
|
||||
document.getElementById('sync-clock-speed-display').textContent = '1';
|
||||
document.getElementById('sync-clock-description').value = '';
|
||||
(document.getElementById('sync-clock-description') as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
@@ -65,17 +66,17 @@ export async function showSyncClockModal(editData) {
|
||||
syncClockModal.snapshot();
|
||||
}
|
||||
|
||||
export async function closeSyncClockModal() {
|
||||
export async function closeSyncClockModal(): Promise<void> {
|
||||
await syncClockModal.close();
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
export async function saveSyncClock() {
|
||||
const id = document.getElementById('sync-clock-id').value;
|
||||
const name = document.getElementById('sync-clock-name').value.trim();
|
||||
const speed = parseFloat(document.getElementById('sync-clock-speed').value);
|
||||
const description = document.getElementById('sync-clock-description').value.trim() || null;
|
||||
export async function saveSyncClock(): Promise<void> {
|
||||
const id = (document.getElementById('sync-clock-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('sync-clock-name') as HTMLInputElement).value.trim();
|
||||
const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value);
|
||||
const description = (document.getElementById('sync-clock-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
if (!name) {
|
||||
syncClockModal.showError(t('sync_clock.error.name_required'));
|
||||
@@ -108,7 +109,7 @@ export async function saveSyncClock() {
|
||||
|
||||
// ── Edit / Clone / Delete ──
|
||||
|
||||
export async function editSyncClock(clockId) {
|
||||
export async function editSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
@@ -120,7 +121,7 @@ export async function editSyncClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneSyncClock(clockId) {
|
||||
export async function cloneSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
@@ -134,7 +135,7 @@ export async function cloneSyncClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSyncClock(clockId) {
|
||||
export async function deleteSyncClock(clockId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
@@ -154,7 +155,7 @@ export async function deleteSyncClock(clockId) {
|
||||
|
||||
// ── Runtime controls ──
|
||||
|
||||
export async function pauseSyncClock(clockId) {
|
||||
export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -167,7 +168,7 @@ export async function pauseSyncClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeSyncClock(clockId) {
|
||||
export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -180,7 +181,7 @@ export async function resumeSyncClock(clockId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetSyncClock(clockId) {
|
||||
export async function resetSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
@@ -195,7 +196,7 @@ export async function resetSyncClock(clockId) {
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
function _formatElapsed(seconds) {
|
||||
function _formatElapsed(seconds: number): string {
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
@@ -204,7 +205,7 @@ function _formatElapsed(seconds) {
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function createSyncClockCard(clock) {
|
||||
export function createSyncClockCard(clock: SyncClock) {
|
||||
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
|
||||
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
|
||||
const toggleAction = clock.is_running
|
||||
@@ -2,10 +2,10 @@
|
||||
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
|
||||
*/
|
||||
|
||||
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js';
|
||||
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
|
||||
|
||||
/** Parse location.hash into {tab, subTab}. */
|
||||
export function parseHash() {
|
||||
export function parseHash(): { tab?: string; subTab?: string } {
|
||||
const hash = location.hash.replace(/^#/, '');
|
||||
if (!hash) return {};
|
||||
const [tab, subTab] = hash.split('/');
|
||||
@@ -13,24 +13,24 @@ export function parseHash() {
|
||||
}
|
||||
|
||||
/** Update the URL hash without triggering popstate. */
|
||||
function _setHash(tab, subTab) {
|
||||
function _setHash(tab: string, subTab: string | null): void {
|
||||
const hash = '#' + (subTab ? `${tab}/${subTab}` : tab);
|
||||
history.replaceState(null, '', hash);
|
||||
}
|
||||
|
||||
let _suppressHashUpdate = false;
|
||||
let _activeTab = null;
|
||||
let _activeTab: string | null = null;
|
||||
|
||||
const _tabScrollPositions = {};
|
||||
const _tabScrollPositions: Record<string, number> = {};
|
||||
|
||||
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
export function switchTab(name: string, { updateHash = true, skipLoad = false }: { updateHash?: boolean; skipLoad?: boolean } = {}): void {
|
||||
if (_activeTab === name) return;
|
||||
// Save scroll position of the tab we're leaving
|
||||
if (_activeTab) _tabScrollPositions[_activeTab] = window.scrollY;
|
||||
_activeTab = name;
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.tab === name;
|
||||
const isActive = (btn as HTMLElement).dataset.tab === name;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
localStorage.setItem('activeTab', name);
|
||||
|
||||
// Update background tab indicator
|
||||
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(name);
|
||||
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator();
|
||||
|
||||
// Restore scroll position for this tab
|
||||
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
|
||||
@@ -76,7 +76,7 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function initTabs() {
|
||||
export function initTabs(): void {
|
||||
// Hash takes priority over localStorage
|
||||
const hashRoute = parseHash();
|
||||
let saved;
|
||||
@@ -97,23 +97,23 @@ export function initTabs() {
|
||||
}
|
||||
|
||||
/** Update hash when sub-tab changes. Called from targets.js / streams.js. */
|
||||
export function updateSubTabHash(tab, subTab) {
|
||||
export function updateSubTabHash(tab: string, subTab: string): void {
|
||||
_setHash(tab, subTab);
|
||||
}
|
||||
|
||||
/** Update the count badge on a main tab button. Hidden when count is 0. */
|
||||
export function updateTabBadge(tabName, count) {
|
||||
export function updateTabBadge(tabName: string, count: number): void {
|
||||
const badge = document.getElementById(`tab-badge-${tabName}`);
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count;
|
||||
badge.textContent = String(count);
|
||||
badge.style.display = '';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoRefresh() {
|
||||
export function startAutoRefresh(): void {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export function startAutoRefresh() {
|
||||
* Handle browser back/forward via popstate.
|
||||
* Called from app.js.
|
||||
*/
|
||||
export function handlePopState() {
|
||||
export function handlePopState(): void {
|
||||
const hashRoute = parseHash();
|
||||
if (!hashRoute.tab) return;
|
||||
|
||||
@@ -11,36 +11,37 @@ import {
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
streamsCache, audioSourcesCache, syncClocksCache,
|
||||
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.js';
|
||||
import { _splitOpenrgbZone } from './device-discovery.js';
|
||||
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
|
||||
import { _splitOpenrgbZone } from './device-discovery.ts';
|
||||
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
|
||||
import {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
||||
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
|
||||
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
|
||||
ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
|
||||
} from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { createFpsSparkline } from '../core/chart-utils.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { TreeNav } from '../core/tree-nav.js';
|
||||
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||
} from '../core/icons.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { IconSelect } from '../core/icon-select.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 { 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 type { OutputTarget } from '../types.ts';
|
||||
|
||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
|
||||
// ── Bulk action handlers ──
|
||||
async function _bulkStartTargets(ids) {
|
||||
async function _bulkStartTargets(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/output-targets/${id}/start`, { method: 'POST' })
|
||||
));
|
||||
@@ -49,7 +50,7 @@ async function _bulkStartTargets(ids) {
|
||||
else showToast(t('device.started'), 'success');
|
||||
}
|
||||
|
||||
async function _bulkStopTargets(ids) {
|
||||
async function _bulkStopTargets(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/output-targets/${id}/stop`, { method: 'POST' })
|
||||
));
|
||||
@@ -58,7 +59,7 @@ async function _bulkStopTargets(ids) {
|
||||
else showToast(t('device.stopped'), 'success');
|
||||
}
|
||||
|
||||
async function _bulkDeleteTargets(ids) {
|
||||
async function _bulkDeleteTargets(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/output-targets/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
@@ -69,7 +70,7 @@ async function _bulkDeleteTargets(ids) {
|
||||
await loadTargetsTab();
|
||||
}
|
||||
|
||||
async function _bulkDeleteDevices(ids) {
|
||||
async function _bulkDeleteDevices(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/devices/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
@@ -80,7 +81,7 @@ async function _bulkDeleteDevices(ids) {
|
||||
await loadTargetsTab();
|
||||
}
|
||||
|
||||
async function _bulkDeletePatternTemplates(ids) {
|
||||
async function _bulkDeletePatternTemplates(ids: any) {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
@@ -118,7 +119,7 @@ const _targetFpsHistory = {}; // fps_actual (rolling avg)
|
||||
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
|
||||
const _targetFpsCharts = {};
|
||||
|
||||
function _pushTargetFps(targetId, actual, current) {
|
||||
function _pushTargetFps(targetId: any, actual: any, current: any) {
|
||||
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
|
||||
const h = _targetFpsHistory[targetId];
|
||||
h.push(actual);
|
||||
@@ -130,11 +131,11 @@ function _pushTargetFps(targetId, actual, current) {
|
||||
if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift();
|
||||
}
|
||||
|
||||
function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) {
|
||||
function _createTargetFpsChart(canvasId: any, actualHistory: any, currentHistory: any, fpsTarget: any, maxHwFps: any) {
|
||||
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps });
|
||||
}
|
||||
|
||||
function _updateTargetFpsChart(targetId, fpsTarget) {
|
||||
function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
|
||||
const chart = _targetFpsCharts[targetId];
|
||||
if (!chart) return;
|
||||
const actualH = _targetFpsHistory[targetId] || [];
|
||||
@@ -154,7 +155,7 @@ function _updateTargetFpsChart(targetId, fpsTarget) {
|
||||
|
||||
// --- Editor state ---
|
||||
let _editorCssSources = []; // populated when editor opens
|
||||
let _targetTagsInput = null;
|
||||
let _targetTagsInput: TagInput | null = null;
|
||||
|
||||
class TargetEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -163,15 +164,15 @@ class TargetEditorModal extends Modal {
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
protocol: document.getElementById('target-editor-protocol').value,
|
||||
css_source: document.getElementById('target-editor-css-source').value,
|
||||
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
|
||||
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
|
||||
name: (document.getElementById('target-editor-name') as HTMLInputElement).value,
|
||||
device: (document.getElementById('target-editor-device') as HTMLSelectElement).value,
|
||||
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
|
||||
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
|
||||
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
|
||||
brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
|
||||
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
|
||||
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
||||
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -179,7 +180,7 @@ class TargetEditorModal extends Modal {
|
||||
|
||||
const targetEditorModal = new TargetEditorModal();
|
||||
|
||||
function _protocolBadge(device, target) {
|
||||
function _protocolBadge(device: any, target: any) {
|
||||
const dt = device?.device_type;
|
||||
if (!dt || dt === 'wled') {
|
||||
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
|
||||
@@ -201,18 +202,18 @@ let _targetNameManuallyEdited = false;
|
||||
|
||||
function _autoGenerateTargetName() {
|
||||
if (_targetNameManuallyEdited) return;
|
||||
if (document.getElementById('target-editor-id').value) return;
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
if ((document.getElementById('target-editor-id') as HTMLInputElement).value) return;
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
const cssSelect = document.getElementById('target-editor-css-source');
|
||||
const cssSelect = document.getElementById('target-editor-css-source') as HTMLSelectElement;
|
||||
const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!deviceName || !cssName) return;
|
||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = `${deviceName} \u00b7 ${cssName}`;
|
||||
}
|
||||
|
||||
function _updateFpsRecommendation() {
|
||||
const el = document.getElementById('target-editor-fps-rec');
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const el = document.getElementById('target-editor-fps-rec') as HTMLElement;
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
if (!device || !device.led_count) {
|
||||
el.style.display = 'none';
|
||||
@@ -228,8 +229,8 @@ function _updateFpsRecommendation() {
|
||||
}
|
||||
|
||||
function _updateDeviceInfo() {
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const el = document.getElementById('target-editor-device-info');
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
const el = document.getElementById('target-editor-device-info') as HTMLElement;
|
||||
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
if (device && device.led_count) {
|
||||
el.textContent = `${device.led_count} LEDs`;
|
||||
@@ -240,15 +241,15 @@ function _updateDeviceInfo() {
|
||||
}
|
||||
|
||||
function _updateKeepaliveVisibility() {
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
const keepaliveGroup = document.getElementById('target-editor-keepalive-group') as HTMLElement;
|
||||
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
const caps = selectedDevice?.capabilities || [];
|
||||
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
||||
}
|
||||
|
||||
function _updateSpecificSettingsVisibility() {
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
|
||||
// Hide WLED-only controls (protocol + keepalive) for non-WLED devices
|
||||
@@ -261,24 +262,24 @@ function _updateSpecificSettingsVisibility() {
|
||||
|
||||
function _updateBrightnessThresholdVisibility() {
|
||||
// Always visible — threshold considers both brightness source and pixel content
|
||||
document.getElementById('target-editor-brightness-threshold-group').style.display = '';
|
||||
(document.getElementById('target-editor-brightness-threshold-group') as HTMLElement).style.display = '';
|
||||
}
|
||||
|
||||
// ── EntitySelect instances for target editor ──
|
||||
let _deviceEntitySelect = null;
|
||||
let _cssEntitySelect = null;
|
||||
let _brightnessVsEntitySelect = null;
|
||||
let _protocolIconSelect = null;
|
||||
let _deviceEntitySelect: EntitySelect | null = null;
|
||||
let _cssEntitySelect: EntitySelect | null = null;
|
||||
let _brightnessVsEntitySelect: EntitySelect | null = null;
|
||||
let _protocolIconSelect: IconSelect | null = null;
|
||||
|
||||
function _populateCssDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-css-source');
|
||||
const select = document.getElementById('target-editor-css-source') as HTMLSelectElement;
|
||||
select.innerHTML = _editorCssSources.map(s =>
|
||||
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function _populateBrightnessVsDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-brightness-vs');
|
||||
const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement;
|
||||
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
||||
_cachedValueSources.forEach(vs => {
|
||||
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
|
||||
@@ -290,7 +291,7 @@ function _ensureTargetEntitySelects() {
|
||||
// Device
|
||||
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
|
||||
_deviceEntitySelect = new EntitySelect({
|
||||
target: document.getElementById('target-editor-device'),
|
||||
target: document.getElementById('target-editor-device') as HTMLSelectElement,
|
||||
getItems: () => _targetEditorDevices.map(d => ({
|
||||
value: d.id,
|
||||
label: d.name,
|
||||
@@ -303,7 +304,7 @@ function _ensureTargetEntitySelects() {
|
||||
// CSS source
|
||||
if (_cssEntitySelect) _cssEntitySelect.destroy();
|
||||
_cssEntitySelect = new EntitySelect({
|
||||
target: document.getElementById('target-editor-css-source'),
|
||||
target: document.getElementById('target-editor-css-source') as HTMLSelectElement,
|
||||
getItems: () => _editorCssSources.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
@@ -316,7 +317,7 @@ function _ensureTargetEntitySelects() {
|
||||
// Brightness value source
|
||||
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||
_brightnessVsEntitySelect = new EntitySelect({
|
||||
target: document.getElementById('target-editor-brightness-vs'),
|
||||
target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement,
|
||||
getItems: () => _cachedValueSources.map(vs => ({
|
||||
value: vs.id,
|
||||
label: vs.name,
|
||||
@@ -329,7 +330,7 @@ function _ensureTargetEntitySelects() {
|
||||
});
|
||||
}
|
||||
|
||||
const _pIcon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _pIcon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _ensureProtocolIconSelect() {
|
||||
const sel = document.getElementById('target-editor-protocol');
|
||||
@@ -339,7 +340,7 @@ function _ensureProtocolIconSelect() {
|
||||
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
|
||||
];
|
||||
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
|
||||
_protocolIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
||||
}
|
||||
|
||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
@@ -355,7 +356,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
_editorCssSources = cssSources;
|
||||
|
||||
// Populate device select
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
|
||||
deviceSelect.innerHTML = '';
|
||||
devices.forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -375,62 +376,62 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
|
||||
document.getElementById('target-editor-id').value = target.id;
|
||||
document.getElementById('target-editor-name').value = target.name;
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
|
||||
deviceSelect.value = target.device_id || '';
|
||||
const fps = target.fps ?? 30;
|
||||
document.getElementById('target-editor-fps').value = fps;
|
||||
document.getElementById('target-editor-fps-value').textContent = fps;
|
||||
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
|
||||
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
|
||||
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
||||
|
||||
const thresh = target.min_brightness_threshold ?? 0;
|
||||
document.getElementById('target-editor-brightness-threshold').value = thresh;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = thresh;
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false;
|
||||
document.getElementById('target-editor-protocol').value = target.protocol || 'ddp';
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
_editorTags = cloneData.tags || [];
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
deviceSelect.value = cloneData.device_id || '';
|
||||
const fps = cloneData.fps ?? 30;
|
||||
document.getElementById('target-editor-fps').value = fps;
|
||||
document.getElementById('target-editor-fps-value').textContent = fps;
|
||||
document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0;
|
||||
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
|
||||
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
|
||||
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
|
||||
document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh;
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false;
|
||||
document.getElementById('target-editor-protocol').value = cloneData.protocol || 'ddp';
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = '';
|
||||
document.getElementById('target-editor-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
||||
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
||||
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
|
||||
document.getElementById('target-editor-brightness-threshold').value = 0;
|
||||
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
|
||||
|
||||
document.getElementById('target-editor-adaptive-fps').checked = false;
|
||||
document.getElementById('target-editor-protocol').value = 'ddp';
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||
|
||||
_populateCssDropdown('');
|
||||
_populateBrightnessVsDropdown('');
|
||||
@@ -439,15 +440,15 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
// Entity palette selectors
|
||||
_ensureTargetEntitySelects();
|
||||
_ensureProtocolIconSelect();
|
||||
if (_protocolIconSelect) _protocolIconSelect.setValue(document.getElementById('target-editor-protocol').value);
|
||||
if (_protocolIconSelect) _protocolIconSelect.setValue((document.getElementById('target-editor-protocol') as HTMLSelectElement).value);
|
||||
|
||||
// Auto-name generation
|
||||
_targetNameManuallyEdited = !!(targetId || cloneData);
|
||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).oninput = () => { _targetNameManuallyEdited = true; };
|
||||
window._targetAutoName = _autoGenerateTargetName;
|
||||
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
|
||||
document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); };
|
||||
(document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); };
|
||||
(document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); };
|
||||
if (!targetId && !cloneData) _autoGenerateTargetName();
|
||||
|
||||
// Show/hide conditional fields
|
||||
@@ -467,7 +468,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
targetEditorModal.snapshot();
|
||||
targetEditorModal.open();
|
||||
|
||||
document.getElementById('target-editor-error').style.display = 'none';
|
||||
(document.getElementById('target-editor-error') as HTMLElement).style.display = 'none';
|
||||
setTimeout(() => desktopFocus(document.getElementById('target-editor-name')), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to open target editor:', error);
|
||||
@@ -489,26 +490,26 @@ export function forceCloseTargetEditorModal() {
|
||||
}
|
||||
|
||||
export async function saveTargetEditor() {
|
||||
const targetId = document.getElementById('target-editor-id').value;
|
||||
const name = document.getElementById('target-editor-name').value.trim();
|
||||
const deviceId = document.getElementById('target-editor-device').value;
|
||||
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
||||
const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim();
|
||||
const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value;
|
||||
const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value);
|
||||
|
||||
if (!name) {
|
||||
targetEditorModal.showError(t('targets.error.name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
||||
const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30;
|
||||
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
|
||||
|
||||
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
||||
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
|
||||
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
|
||||
const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0;
|
||||
|
||||
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
|
||||
const protocol = document.getElementById('target-editor-protocol').value;
|
||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
@@ -557,16 +558,16 @@ export async function saveTargetEditor() {
|
||||
let _treeTriggered = false;
|
||||
|
||||
const _targetsTree = new TreeNav('targets-tree-nav', {
|
||||
onSelect: (key) => {
|
||||
onSelect: (key: any) => {
|
||||
_treeTriggered = true;
|
||||
switchTargetSubTab(key);
|
||||
_treeTriggered = false;
|
||||
}
|
||||
});
|
||||
|
||||
export function switchTargetSubTab(tabKey) {
|
||||
export function switchTargetSubTab(tabKey: any) {
|
||||
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
|
||||
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
||||
(panel as HTMLElement).classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
||||
);
|
||||
localStorage.setItem('activeTargetSubTab', tabKey);
|
||||
updateSubTabHash('targets', tabKey);
|
||||
@@ -715,7 +716,7 @@ export async function loadTargetsTab() {
|
||||
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
||||
|
||||
// Re-render cached LED preview frames onto new canvas elements after reconciliation
|
||||
for (const id of ledResult.replaced) {
|
||||
for (const id of Array.from(ledResult.replaced) as any[]) {
|
||||
const frame = _ledPreviewLastFrame[id];
|
||||
if (frame && ledPreviewWebSockets[id]) {
|
||||
const canvas = document.getElementById(`led-preview-canvas-${id}`);
|
||||
@@ -742,8 +743,8 @@ export async function loadTargetsTab() {
|
||||
// Show/hide stop-all buttons based on running state
|
||||
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
|
||||
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
|
||||
const ledStopBtn = container.querySelector('[data-stop-all="led"]');
|
||||
const kcStopBtn = container.querySelector('[data-stop-all="kc"]');
|
||||
const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null;
|
||||
const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null;
|
||||
if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
|
||||
if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
|
||||
|
||||
@@ -761,9 +762,9 @@ export async function loadTargetsTab() {
|
||||
if ((device.capabilities || []).includes('brightness_control')) {
|
||||
if (device.id in _deviceBrightnessCache) {
|
||||
const bri = _deviceBrightnessCache[device.id];
|
||||
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
|
||||
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`) as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
slider.value = bri;
|
||||
slider.value = String(bri);
|
||||
slider.title = Math.round(bri / 255 * 100) + '%';
|
||||
slider.disabled = false;
|
||||
}
|
||||
@@ -781,7 +782,7 @@ export async function loadTargetsTab() {
|
||||
|
||||
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
|
||||
for (const device of devicesWithState) {
|
||||
const el = container.querySelector(`[data-last-seen="${device.id}"]`);
|
||||
const el = container.querySelector(`[data-last-seen="${device.id}"]`) as HTMLElement | null;
|
||||
if (el) {
|
||||
const ts = device.state?.device_last_checked;
|
||||
const label = ts ? formatRelativeTime(ts) : null;
|
||||
@@ -875,7 +876,7 @@ export async function loadTargetsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
function _cssSourceName(cssId, colorStripSourceMap) {
|
||||
function _cssSourceName(cssId: any, colorStripSourceMap: any) {
|
||||
if (!cssId) return t('targets.no_css');
|
||||
const css = colorStripSourceMap[cssId];
|
||||
return css ? escapeHtml(css.name) : escapeHtml(cssId);
|
||||
@@ -883,7 +884,7 @@ function _cssSourceName(cssId, colorStripSourceMap) {
|
||||
|
||||
// ─── In-place metric patching (avoids full card replacement on polls) ───
|
||||
|
||||
function _buildLedTimingHTML(state) {
|
||||
function _buildLedTimingHTML(state: any) {
|
||||
const isAudio = state.timing_audio_read_ms != null;
|
||||
return `
|
||||
<div class="timing-header">
|
||||
@@ -916,7 +917,7 @@ function _buildLedTimingHTML(state) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _patchTargetMetrics(target) {
|
||||
function _patchTargetMetrics(target: any) {
|
||||
const container = document.getElementById('targets-panel-content');
|
||||
if (!container) return;
|
||||
const card = container.querySelector(`[data-target-id="${target.id}"]`);
|
||||
@@ -924,7 +925,7 @@ function _patchTargetMetrics(target) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
|
||||
const fps = card.querySelector('[data-tm="fps"]');
|
||||
const fps = card.querySelector('[data-tm="fps"]') as HTMLElement | null;
|
||||
if (fps) {
|
||||
const effFps = state.fps_effective;
|
||||
const tgtFps = state.fps_target || 0;
|
||||
@@ -937,7 +938,7 @@ function _patchTargetMetrics(target) {
|
||||
}
|
||||
|
||||
// Update health dot to reflect streaming reachability when processing
|
||||
const healthDot = card.querySelector('.health-dot');
|
||||
const healthDot = card.querySelector('.health-dot') as HTMLElement | null;
|
||||
if (healthDot && state.processing) {
|
||||
const reachable = state.device_streaming_reachable;
|
||||
if (reachable === false) {
|
||||
@@ -949,16 +950,16 @@ function _patchTargetMetrics(target) {
|
||||
}
|
||||
}
|
||||
|
||||
const timing = card.querySelector('[data-tm="timing"]');
|
||||
const timing = card.querySelector('[data-tm="timing"]') as HTMLElement | null;
|
||||
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
|
||||
|
||||
const frames = card.querySelector('[data-tm="frames"]');
|
||||
const frames = card.querySelector('[data-tm="frames"]') as HTMLElement | null;
|
||||
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
|
||||
|
||||
const keepalive = card.querySelector('[data-tm="keepalive"]');
|
||||
const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement | null;
|
||||
if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }
|
||||
|
||||
const errors = card.querySelector('[data-tm="errors"]');
|
||||
const errors = card.querySelector('[data-tm="errors"]') as HTMLElement | null;
|
||||
if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }
|
||||
|
||||
// Error indicator near target name
|
||||
@@ -968,11 +969,11 @@ function _patchTargetMetrics(target) {
|
||||
errorIndicator.classList.toggle('visible', hasErrors);
|
||||
}
|
||||
|
||||
const uptime = card.querySelector('[data-tm="uptime"]');
|
||||
const uptime = card.querySelector('[data-tm="uptime"]') as HTMLElement | null;
|
||||
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
|
||||
}
|
||||
|
||||
export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSourceMap) {
|
||||
export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
|
||||
const state = target.state || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
@@ -1088,7 +1089,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
||||
});
|
||||
}
|
||||
|
||||
async function _targetAction(action) {
|
||||
async function _targetAction(action: any) {
|
||||
_actionInFlight = true;
|
||||
try {
|
||||
await action();
|
||||
@@ -1099,7 +1100,7 @@ async function _targetAction(action) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTargetProcessing(targetId) {
|
||||
export async function startTargetProcessing(targetId: any) {
|
||||
await _targetAction(async () => {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
|
||||
method: 'POST',
|
||||
@@ -1113,7 +1114,7 @@ export async function startTargetProcessing(targetId) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopTargetProcessing(targetId) {
|
||||
export async function stopTargetProcessing(targetId: any) {
|
||||
await _targetAction(async () => {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
|
||||
method: 'POST',
|
||||
@@ -1139,7 +1140,7 @@ export async function stopAllKCTargets() {
|
||||
await _stopAllByType('key_colors');
|
||||
}
|
||||
|
||||
async function _stopAllByType(targetType) {
|
||||
async function _stopAllByType(targetType: any) {
|
||||
try {
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
@@ -1164,7 +1165,7 @@ async function _stopAllByType(targetType) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTargetOverlay(targetId) {
|
||||
export async function startTargetOverlay(targetId: any) {
|
||||
await _targetAction(async () => {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/start`, {
|
||||
method: 'POST',
|
||||
@@ -1178,7 +1179,7 @@ export async function startTargetOverlay(targetId) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopTargetOverlay(targetId) {
|
||||
export async function stopTargetOverlay(targetId: any) {
|
||||
await _targetAction(async () => {
|
||||
const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/stop`, {
|
||||
method: 'POST',
|
||||
@@ -1192,7 +1193,7 @@ export async function stopTargetOverlay(targetId) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function cloneTarget(targetId) {
|
||||
export async function cloneTarget(targetId: any) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
@@ -1204,7 +1205,7 @@ export async function cloneTarget(targetId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTarget(targetId) {
|
||||
export async function deleteTarget(targetId: any) {
|
||||
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1232,7 +1233,7 @@ const _ledPreviewLastFrame = {};
|
||||
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
|
||||
* one canvas per zone with labels. Otherwise, a single canvas.
|
||||
*/
|
||||
function _buildLedPreviewHtml(targetId, device, bvsId, cssSource, colorStripSourceMap) {
|
||||
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
|
||||
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
||||
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
||||
|
||||
@@ -1284,7 +1285,7 @@ function _buildLedPreviewHtml(targetId, device, bvsId, cssSource, colorStripSour
|
||||
* Resample an RGB byte array from srcCount pixels to dstCount pixels
|
||||
* using linear interpolation (matches backend np.interp behavior).
|
||||
*/
|
||||
function _resampleStrip(srcBytes, srcCount, dstCount) {
|
||||
function _resampleStrip(srcBytes: any, srcCount: any, dstCount: any) {
|
||||
if (dstCount === srcCount) return srcBytes;
|
||||
const dst = new Uint8Array(dstCount * 3);
|
||||
for (let i = 0; i < dstCount; i++) {
|
||||
@@ -1306,7 +1307,7 @@ function _resampleStrip(srcBytes, srcCount, dstCount) {
|
||||
* Render per-zone LED previews: resample the full frame independently
|
||||
* for each zone canvas (matching the backend's separate-mode behavior).
|
||||
*/
|
||||
function _renderLedStripZones(panel, rgbBytes) {
|
||||
function _renderLedStripZones(panel: any, rgbBytes: any) {
|
||||
const baseUrl = panel.dataset.zoneBaseUrl;
|
||||
const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
|
||||
const srcCount = Math.floor(rgbBytes.length / 3);
|
||||
@@ -1330,7 +1331,7 @@ function _renderLedStripZones(panel, rgbBytes) {
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLedStrip(canvas, rgbBytes) {
|
||||
function _renderLedStrip(canvas: any, rgbBytes: any) {
|
||||
const ledCount = rgbBytes.length / 3;
|
||||
if (ledCount <= 0) return;
|
||||
|
||||
@@ -1354,7 +1355,7 @@ function _renderLedStrip(canvas, rgbBytes) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function connectLedPreviewWS(targetId) {
|
||||
function connectLedPreviewWS(targetId: any) {
|
||||
disconnectLedPreviewWS(targetId);
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
@@ -1439,7 +1440,7 @@ function connectLedPreviewWS(targetId) {
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectLedPreviewWS(targetId) {
|
||||
function disconnectLedPreviewWS(targetId: any) {
|
||||
const ws = ledPreviewWebSockets[targetId];
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
@@ -1455,7 +1456,7 @@ export function disconnectAllLedPreviewWS() {
|
||||
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
|
||||
}
|
||||
|
||||
export function toggleLedPreview(targetId) {
|
||||
export function toggleLedPreview(targetId: any) {
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (!panel) return;
|
||||
|
||||
@@ -2,10 +2,26 @@
|
||||
* Tutorial system — generic engine, steps, tooltip positioning.
|
||||
*/
|
||||
|
||||
import { activeTutorial, setActiveTutorial } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { activeTutorial, setActiveTutorial } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
|
||||
const calibrationTutorialSteps = [
|
||||
interface TutorialStep {
|
||||
selector: string;
|
||||
textKey: string;
|
||||
position: string;
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
interface TutorialConfig {
|
||||
steps: TutorialStep[];
|
||||
overlayId: string;
|
||||
mode: string;
|
||||
container: Element | null;
|
||||
resolveTarget: (step: TutorialStep) => Element | null;
|
||||
onClose?: (() => void) | null;
|
||||
}
|
||||
|
||||
const calibrationTutorialSteps: TutorialStep[] = [
|
||||
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
||||
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
||||
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
||||
@@ -21,7 +37,7 @@ const calibrationTutorialSteps = [
|
||||
|
||||
const TOUR_KEY = 'tour_completed';
|
||||
|
||||
const gettingStartedSteps = [
|
||||
const gettingStartedSteps: TutorialStep[] = [
|
||||
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
||||
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' },
|
||||
{ selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
|
||||
@@ -36,21 +52,21 @@ const gettingStartedSteps = [
|
||||
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
|
||||
];
|
||||
|
||||
const dashboardTutorialSteps = [
|
||||
const dashboardTutorialSteps: TutorialStep[] = [
|
||||
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' }
|
||||
];
|
||||
|
||||
const targetsTutorialSteps = [
|
||||
const targetsTutorialSteps: TutorialStep[] = [
|
||||
{ selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' },
|
||||
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
|
||||
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
|
||||
{ selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' }
|
||||
];
|
||||
|
||||
const sourcesTourSteps = [
|
||||
const sourcesTourSteps: TutorialStep[] = [
|
||||
{ selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' },
|
||||
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
|
||||
{ selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' },
|
||||
@@ -61,7 +77,7 @@ const sourcesTourSteps = [
|
||||
{ selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' }
|
||||
];
|
||||
|
||||
const automationsTutorialSteps = [
|
||||
const automationsTutorialSteps: TutorialStep[] = [
|
||||
{ selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' },
|
||||
{ selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' },
|
||||
{ selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' },
|
||||
@@ -70,15 +86,15 @@ const automationsTutorialSteps = [
|
||||
{ selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom' },
|
||||
];
|
||||
|
||||
const _fixedResolve = (step) => {
|
||||
const _fixedResolve = (step: TutorialStep): Element | null => {
|
||||
const el = document.querySelector(step.selector);
|
||||
if (!el) return null;
|
||||
// offsetParent is null when element or any ancestor has display:none
|
||||
if (!el.offsetParent) return null;
|
||||
if (!(el as HTMLElement).offsetParent) return null;
|
||||
return el;
|
||||
};
|
||||
|
||||
const deviceTutorialSteps = [
|
||||
const deviceTutorialSteps: TutorialStep[] = [
|
||||
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
|
||||
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
|
||||
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
|
||||
@@ -88,10 +104,10 @@ const deviceTutorialSteps = [
|
||||
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
|
||||
];
|
||||
|
||||
export function startTutorial(config) {
|
||||
export function startTutorial(config: TutorialConfig): void {
|
||||
closeTutorial();
|
||||
// Remove focus from the trigger button so its outline doesn't persist
|
||||
if (document.activeElement) document.activeElement.blur();
|
||||
if (document.activeElement) (document.activeElement as HTMLElement).blur();
|
||||
const overlay = document.getElementById(config.overlayId);
|
||||
if (!overlay) return;
|
||||
|
||||
@@ -109,16 +125,16 @@ export function startTutorial(config) {
|
||||
const tooltip = overlay.querySelector('.tutorial-tooltip');
|
||||
const ring = overlay.querySelector('.tutorial-ring');
|
||||
const backdrop = overlay.querySelector('.tutorial-backdrop');
|
||||
if (tooltip) tooltip.style.visibility = 'hidden';
|
||||
if (ring) ring.style.visibility = 'hidden';
|
||||
if (backdrop) backdrop.style.clipPath = 'none';
|
||||
if (tooltip) (tooltip as HTMLElement).style.visibility = 'hidden';
|
||||
if (ring) (ring as HTMLElement).style.visibility = 'hidden';
|
||||
if (backdrop) (backdrop as HTMLElement).style.clipPath = 'none';
|
||||
|
||||
overlay.classList.add('active');
|
||||
document.addEventListener('keydown', handleTutorialKey);
|
||||
showTutorialStep(0);
|
||||
}
|
||||
|
||||
export function startCalibrationTutorial() {
|
||||
export function startCalibrationTutorial(): void {
|
||||
const container = document.querySelector('#calibration-modal .modal-body');
|
||||
if (!container) return;
|
||||
startTutorial({
|
||||
@@ -126,8 +142,8 @@ export function startCalibrationTutorial() {
|
||||
overlayId: 'calibration-tutorial-overlay',
|
||||
mode: 'absolute',
|
||||
container: container,
|
||||
resolveTarget: (step) => {
|
||||
const el = document.querySelector(step.selector);
|
||||
resolveTarget: (step: TutorialStep): Element | null => {
|
||||
const el = document.querySelector(step.selector) as HTMLElement | null;
|
||||
if (!el) return null;
|
||||
// Skip elements hidden via display:none (e.g. overlay btn in device mode)
|
||||
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') return null;
|
||||
@@ -136,7 +152,7 @@ export function startCalibrationTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function startDeviceTutorial(deviceId) {
|
||||
export function startDeviceTutorial(deviceId?: string): void {
|
||||
const selector = deviceId
|
||||
? `.card[data-device-id="${deviceId}"]`
|
||||
: '.card[data-device-id]';
|
||||
@@ -146,7 +162,7 @@ export function startDeviceTutorial(deviceId) {
|
||||
overlayId: 'device-tutorial-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: (step) => {
|
||||
resolveTarget: (step: TutorialStep): Element | null => {
|
||||
const card = document.querySelector(selector);
|
||||
if (!card) return null;
|
||||
return step.global
|
||||
@@ -156,18 +172,18 @@ export function startDeviceTutorial(deviceId) {
|
||||
});
|
||||
}
|
||||
|
||||
export function startGettingStartedTutorial() {
|
||||
export function startGettingStartedTutorial(): void {
|
||||
startTutorial({
|
||||
steps: gettingStartedSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: (step) => document.querySelector(step.selector) || null,
|
||||
resolveTarget: (step: TutorialStep): Element | null => document.querySelector(step.selector) || null,
|
||||
onClose: () => localStorage.setItem(TOUR_KEY, '1')
|
||||
});
|
||||
}
|
||||
|
||||
export function startDashboardTutorial() {
|
||||
export function startDashboardTutorial(): void {
|
||||
startTutorial({
|
||||
steps: dashboardTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
@@ -177,7 +193,7 @@ export function startDashboardTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function startTargetsTutorial() {
|
||||
export function startTargetsTutorial(): void {
|
||||
startTutorial({
|
||||
steps: targetsTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
@@ -187,7 +203,7 @@ export function startTargetsTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function startSourcesTutorial() {
|
||||
export function startSourcesTutorial(): void {
|
||||
startTutorial({
|
||||
steps: sourcesTourSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
@@ -197,7 +213,7 @@ export function startSourcesTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function startAutomationsTutorial() {
|
||||
export function startAutomationsTutorial(): void {
|
||||
startTutorial({
|
||||
steps: automationsTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
@@ -207,20 +223,20 @@ export function startAutomationsTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTutorial() {
|
||||
export function closeTutorial(): void {
|
||||
if (!activeTutorial) return;
|
||||
const onClose = activeTutorial.onClose;
|
||||
activeTutorial.overlay.classList.remove('active');
|
||||
document.querySelectorAll('.tutorial-target').forEach(el => {
|
||||
el.classList.remove('tutorial-target');
|
||||
el.style.zIndex = '';
|
||||
(el as HTMLElement).style.zIndex = '';
|
||||
});
|
||||
document.removeEventListener('keydown', handleTutorialKey);
|
||||
setActiveTutorial(null);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
export function tutorialNext() {
|
||||
export function tutorialNext(): void {
|
||||
if (!activeTutorial) return;
|
||||
if (activeTutorial.step < activeTutorial.steps.length - 1) {
|
||||
showTutorialStep(activeTutorial.step + 1);
|
||||
@@ -229,14 +245,14 @@ export function tutorialNext() {
|
||||
}
|
||||
}
|
||||
|
||||
export function tutorialPrev() {
|
||||
export function tutorialPrev(): void {
|
||||
if (!activeTutorial) return;
|
||||
if (activeTutorial.step > 0) {
|
||||
showTutorialStep(activeTutorial.step - 1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function _positionSpotlight(target, overlay, step, index, isFixed) {
|
||||
function _positionSpotlight(target: Element, overlay: HTMLElement, step: TutorialStep, index: number, isFixed: boolean): void {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x, y, w, h;
|
||||
@@ -254,7 +270,7 @@ function _positionSpotlight(target, overlay, step, index, isFixed) {
|
||||
h = targetRect.height + pad * 2;
|
||||
}
|
||||
|
||||
const backdrop = overlay.querySelector('.tutorial-backdrop');
|
||||
const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement;
|
||||
if (backdrop) {
|
||||
backdrop.style.clipPath = `polygon(
|
||||
0% 0%, 0% 100%,
|
||||
@@ -264,7 +280,7 @@ function _positionSpotlight(target, overlay, step, index, isFixed) {
|
||||
100% 100%, 100% 0%)`;
|
||||
}
|
||||
|
||||
const ring = overlay.querySelector('.tutorial-ring');
|
||||
const ring = overlay.querySelector('.tutorial-ring') as HTMLElement;
|
||||
if (ring) {
|
||||
ring.style.left = x + 'px';
|
||||
ring.style.top = y + 'px';
|
||||
@@ -272,13 +288,13 @@ function _positionSpotlight(target, overlay, step, index, isFixed) {
|
||||
ring.style.height = h + 'px';
|
||||
}
|
||||
|
||||
const tooltip = overlay.querySelector('.tutorial-tooltip');
|
||||
const tooltip = overlay.querySelector('.tutorial-tooltip') as HTMLElement;
|
||||
const textEl = overlay.querySelector('.tutorial-tooltip-text');
|
||||
const counterEl = overlay.querySelector('.tutorial-step-counter');
|
||||
if (textEl) textEl.textContent = t(step.textKey);
|
||||
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
|
||||
|
||||
const prevBtn = overlay.querySelector('.tutorial-prev-btn');
|
||||
const prevBtn = overlay.querySelector('.tutorial-prev-btn') as HTMLButtonElement;
|
||||
const nextBtn = overlay.querySelector('.tutorial-next-btn');
|
||||
if (prevBtn) prevBtn.disabled = (index === 0);
|
||||
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
|
||||
@@ -291,8 +307,8 @@ function _positionSpotlight(target, overlay, step, index, isFixed) {
|
||||
}
|
||||
|
||||
/** Wait for scroll to settle (position stops changing). */
|
||||
function _waitForScrollEnd() {
|
||||
return new Promise(resolve => {
|
||||
function _waitForScrollEnd(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
let lastY = window.scrollY;
|
||||
let stableFrames = 0;
|
||||
const fallback = setTimeout(resolve, 300);
|
||||
@@ -307,7 +323,7 @@ function _waitForScrollEnd() {
|
||||
});
|
||||
}
|
||||
|
||||
function showTutorialStep(index, direction = 1) {
|
||||
function showTutorialStep(index: number, direction: number = 1): void {
|
||||
if (!activeTutorial) return;
|
||||
activeTutorial.step = index;
|
||||
const step = activeTutorial.steps[index];
|
||||
@@ -316,7 +332,7 @@ function showTutorialStep(index, direction = 1) {
|
||||
|
||||
document.querySelectorAll('.tutorial-target').forEach(el => {
|
||||
el.classList.remove('tutorial-target');
|
||||
el.style.zIndex = '';
|
||||
(el as HTMLElement).style.zIndex = '';
|
||||
});
|
||||
|
||||
const target = activeTutorial.resolveTarget(step);
|
||||
@@ -328,7 +344,7 @@ function showTutorialStep(index, direction = 1) {
|
||||
return;
|
||||
}
|
||||
target.classList.add('tutorial-target');
|
||||
if (isFixed) target.style.zIndex = '10001';
|
||||
if (isFixed) (target as HTMLElement).style.zIndex = '10001';
|
||||
|
||||
// Scroll target into view if off-screen or behind sticky header
|
||||
const preRect = target.getBoundingClientRect();
|
||||
@@ -350,7 +366,7 @@ function showTutorialStep(index, direction = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
|
||||
function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): void {
|
||||
const gap = 12;
|
||||
const tooltipW = 260;
|
||||
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
|
||||
@@ -382,7 +398,7 @@ function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
|
||||
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
|
||||
}
|
||||
|
||||
function handleTutorialKey(e) {
|
||||
function handleTutorialKey(e: KeyboardEvent): void {
|
||||
if (!activeTutorial) return;
|
||||
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
|
||||
@@ -10,29 +10,30 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import {
|
||||
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
||||
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
|
||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
} from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { ValueSource } from '../types.ts';
|
||||
|
||||
export { getValueSourceIcon };
|
||||
|
||||
// ── EntitySelect instances for value source editor ──
|
||||
let _vsAudioSourceEntitySelect = null;
|
||||
let _vsPictureSourceEntitySelect = null;
|
||||
let _vsTagsInput = null;
|
||||
let _vsAudioSourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsPictureSourceEntitySelect: EntitySelect | null = null;
|
||||
let _vsTagsInput: TagInput | null = null;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
constructor() { super('value-source-modal'); }
|
||||
@@ -42,31 +43,31 @@ class ValueSourceModal extends Modal {
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
|
||||
return {
|
||||
name: document.getElementById('value-source-name').value,
|
||||
description: document.getElementById('value-source-description').value,
|
||||
name: (document.getElementById('value-source-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('value-source-description') as HTMLInputElement).value,
|
||||
type,
|
||||
value: document.getElementById('value-source-value').value,
|
||||
waveform: document.getElementById('value-source-waveform').value,
|
||||
speed: document.getElementById('value-source-speed').value,
|
||||
minValue: document.getElementById('value-source-min-value').value,
|
||||
maxValue: document.getElementById('value-source-max-value').value,
|
||||
audioSource: document.getElementById('value-source-audio-source').value,
|
||||
mode: document.getElementById('value-source-mode').value,
|
||||
sensitivity: document.getElementById('value-source-sensitivity').value,
|
||||
smoothing: document.getElementById('value-source-smoothing').value,
|
||||
autoGain: document.getElementById('value-source-auto-gain').checked,
|
||||
adaptiveMin: document.getElementById('value-source-adaptive-min-value').value,
|
||||
adaptiveMax: document.getElementById('value-source-adaptive-max-value').value,
|
||||
pictureSource: document.getElementById('value-source-picture-source').value,
|
||||
sceneBehavior: document.getElementById('value-source-scene-behavior').value,
|
||||
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
|
||||
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
|
||||
value: (document.getElementById('value-source-value') as HTMLInputElement).value,
|
||||
waveform: (document.getElementById('value-source-waveform') as HTMLSelectElement).value,
|
||||
speed: (document.getElementById('value-source-speed') as HTMLInputElement).value,
|
||||
minValue: (document.getElementById('value-source-min-value') as HTMLInputElement).value,
|
||||
maxValue: (document.getElementById('value-source-max-value') as HTMLInputElement).value,
|
||||
audioSource: (document.getElementById('value-source-audio-source') as HTMLSelectElement).value,
|
||||
mode: (document.getElementById('value-source-mode') as HTMLSelectElement).value,
|
||||
sensitivity: (document.getElementById('value-source-sensitivity') as HTMLInputElement).value,
|
||||
smoothing: (document.getElementById('value-source-smoothing') as HTMLInputElement).value,
|
||||
autoGain: (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked,
|
||||
adaptiveMin: (document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value,
|
||||
adaptiveMax: (document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value,
|
||||
pictureSource: (document.getElementById('value-source-picture-source') as HTMLSelectElement).value,
|
||||
sceneBehavior: (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value,
|
||||
sceneSensitivity: (document.getElementById('value-source-scene-sensitivity') as HTMLInputElement).value,
|
||||
sceneSmoothing: (document.getElementById('value-source-scene-smoothing') as HTMLInputElement).value,
|
||||
schedule: JSON.stringify(_getScheduleFromUI()),
|
||||
daylightSpeed: document.getElementById('value-source-daylight-speed').value,
|
||||
daylightRealTime: document.getElementById('value-source-daylight-real-time').checked,
|
||||
daylightLatitude: document.getElementById('value-source-daylight-latitude').value,
|
||||
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
|
||||
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
|
||||
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -80,22 +81,22 @@ let _vsNameManuallyEdited = false;
|
||||
|
||||
function _autoGenerateVSName() {
|
||||
if (_vsNameManuallyEdited) return;
|
||||
if (document.getElementById('value-source-id').value) return;
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
if ((document.getElementById('value-source-id') as HTMLInputElement).value) return;
|
||||
const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
|
||||
const typeLabel = t(`value_source.type.${type}`);
|
||||
let detail = '';
|
||||
if (type === 'animated') {
|
||||
const wf = document.getElementById('value-source-waveform').value;
|
||||
const wf = (document.getElementById('value-source-waveform') as HTMLSelectElement).value;
|
||||
detail = t(`value_source.waveform.${wf}`);
|
||||
} else if (type === 'audio') {
|
||||
const mode = document.getElementById('value-source-mode').value;
|
||||
const mode = (document.getElementById('value-source-mode') as HTMLSelectElement).value;
|
||||
detail = t(`value_source.mode.${mode}`);
|
||||
} else if (type === 'adaptive_scene') {
|
||||
const sel = document.getElementById('value-source-picture-source');
|
||||
const sel = document.getElementById('value-source-picture-source') as HTMLSelectElement;
|
||||
const name = sel?.selectedOptions[0]?.textContent?.trim();
|
||||
if (name) detail = name;
|
||||
}
|
||||
document.getElementById('value-source-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
|
||||
}
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
@@ -111,8 +112,8 @@ function _buildVSTypeItems() {
|
||||
}));
|
||||
}
|
||||
|
||||
let _vsTypeIconSelect = null;
|
||||
let _waveformIconSelect = null;
|
||||
let _vsTypeIconSelect: IconSelect | null = null;
|
||||
let _waveformIconSelect: IconSelect | null = null;
|
||||
|
||||
const _WAVEFORM_SVG = {
|
||||
sine: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
|
||||
@@ -131,7 +132,7 @@ function _ensureWaveformIconSelect() {
|
||||
{ value: 'sawtooth', icon: _WAVEFORM_SVG.sawtooth, label: t('value_source.waveform.sawtooth') },
|
||||
];
|
||||
if (_waveformIconSelect) { _waveformIconSelect.updateItems(items); return; }
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 } as any);
|
||||
}
|
||||
|
||||
/* ── Waveform canvas preview ──────────────────────────────────── */
|
||||
@@ -140,8 +141,8 @@ function _ensureWaveformIconSelect() {
|
||||
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
|
||||
* Shows one full cycle of the selected waveform shape.
|
||||
*/
|
||||
function _drawWaveformPreview(waveformType) {
|
||||
const canvas = document.getElementById('value-source-waveform-preview');
|
||||
function _drawWaveformPreview(waveformType: any) {
|
||||
const canvas = document.getElementById('value-source-waveform-preview') as HTMLCanvasElement;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -212,7 +213,7 @@ function _drawWaveformPreview(waveformType) {
|
||||
}
|
||||
|
||||
export function updateWaveformPreview() {
|
||||
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
|
||||
const wf = (document.getElementById('value-source-waveform') as HTMLSelectElement)?.value || 'sine';
|
||||
_drawWaveformPreview(wf);
|
||||
}
|
||||
|
||||
@@ -224,7 +225,7 @@ const _AUDIO_MODE_SVG = {
|
||||
beat: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L12 12 L16 2 L22 22 L28 6 L32 12 L60 12"/></svg>',
|
||||
};
|
||||
|
||||
let _audioModeIconSelect = null;
|
||||
let _audioModeIconSelect: IconSelect | null = null;
|
||||
|
||||
function _ensureAudioModeIconSelect() {
|
||||
const sel = document.getElementById('value-source-mode');
|
||||
@@ -235,19 +236,19 @@ function _ensureAudioModeIconSelect() {
|
||||
{ value: 'beat', icon: _AUDIO_MODE_SVG.beat, label: t('value_source.mode.beat'), desc: t('value_source.mode.beat.desc') },
|
||||
];
|
||||
if (_audioModeIconSelect) { _audioModeIconSelect.updateItems(items); return; }
|
||||
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||
}
|
||||
|
||||
function _ensureVSTypeIconSelect() {
|
||||
const sel = document.getElementById('value-source-type');
|
||||
if (!sel) return;
|
||||
if (_vsTypeIconSelect) { _vsTypeIconSelect.updateItems(_buildVSTypeItems()); return; }
|
||||
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 });
|
||||
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 } as any);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
export async function showValueSourceModal(editData, presetType = null) {
|
||||
export async function showValueSourceModal(editData: any, presetType: any = null) {
|
||||
// When creating new: show type picker first, then re-enter with presetType
|
||||
if (!editData && !presetType) {
|
||||
showTypePicker({
|
||||
@@ -265,30 +266,30 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
const titleIcon = getValueSourceIcon(sourceType);
|
||||
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
|
||||
const typeName = t(`value_source.type.${sourceType}`);
|
||||
document.getElementById('value-source-modal-title').innerHTML = isEdit
|
||||
(document.getElementById('value-source-modal-title') as HTMLElement).innerHTML = isEdit
|
||||
? `${titleIcon} ${t(titleKey)}`
|
||||
: `${titleIcon} ${t(titleKey)}: ${typeName}`;
|
||||
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('value-source-error').style.display = 'none';
|
||||
(document.getElementById('value-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
|
||||
(document.getElementById('value-source-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
_vsNameManuallyEdited = !!(isEdit || editData);
|
||||
document.getElementById('value-source-name').oninput = () => { _vsNameManuallyEdited = true; };
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).oninput = () => { _vsNameManuallyEdited = true; };
|
||||
|
||||
_ensureVSTypeIconSelect();
|
||||
const typeSelect = document.getElementById('value-source-type');
|
||||
const typeSelect = document.getElementById('value-source-type') as HTMLSelectElement;
|
||||
// Type is chosen before the modal opens — always hide selector
|
||||
document.getElementById('value-source-type-group').style.display = 'none';
|
||||
(document.getElementById('value-source-type-group') as HTMLElement).style.display = 'none';
|
||||
|
||||
if (editData) {
|
||||
document.getElementById('value-source-name').value = editData.name || '';
|
||||
document.getElementById('value-source-description').value = editData.description || '';
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = editData.name || '';
|
||||
(document.getElementById('value-source-description') as HTMLInputElement).value = editData.description || '';
|
||||
typeSelect.value = editData.source_type || 'static';
|
||||
onValueSourceTypeChange();
|
||||
|
||||
if (editData.source_type === 'static') {
|
||||
_setSlider('value-source-value', editData.value ?? 1.0);
|
||||
} else if (editData.source_type === 'animated') {
|
||||
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
|
||||
(document.getElementById('value-source-waveform') as HTMLSelectElement).value = editData.waveform || 'sine';
|
||||
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
|
||||
_drawWaveformPreview(editData.waveform || 'sine');
|
||||
_setSlider('value-source-speed', editData.speed ?? 10);
|
||||
@@ -296,9 +297,9 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
_setSlider('value-source-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'audio') {
|
||||
_populateAudioSourceDropdown(editData.audio_source_id || '');
|
||||
document.getElementById('value-source-mode').value = editData.mode || 'rms';
|
||||
(document.getElementById('value-source-mode') as HTMLSelectElement).value = editData.mode || 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue(editData.mode || 'rms');
|
||||
document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain;
|
||||
(document.getElementById('value-source-auto-gain') as HTMLInputElement).checked = !!editData.auto_gain;
|
||||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||||
_setSlider('value-source-audio-min-value', editData.min_value ?? 0);
|
||||
@@ -309,34 +310,34 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'adaptive_scene') {
|
||||
_populatePictureSourceDropdown(editData.picture_source_id || '');
|
||||
document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement';
|
||||
(document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value = editData.scene_behavior || 'complement';
|
||||
_setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
|
||||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||
} else if (editData.source_type === 'daylight') {
|
||||
_setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
|
||||
document.getElementById('value-source-daylight-real-time').checked = !!editData.use_real_time;
|
||||
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = !!editData.use_real_time;
|
||||
_setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
|
||||
_syncDaylightVSSpeedVisibility();
|
||||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('value-source-name').value = '';
|
||||
document.getElementById('value-source-description').value = '';
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('value-source-description') as HTMLInputElement).value = '';
|
||||
typeSelect.value = presetType || 'static';
|
||||
onValueSourceTypeChange();
|
||||
_setSlider('value-source-value', 1.0);
|
||||
_setSlider('value-source-speed', 10);
|
||||
_setSlider('value-source-min-value', 0);
|
||||
_setSlider('value-source-max-value', 1);
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
(document.getElementById('value-source-waveform') as HTMLSelectElement).value = 'sine';
|
||||
_drawWaveformPreview('sine');
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
(document.getElementById('value-source-mode') as HTMLSelectElement).value = 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
|
||||
document.getElementById('value-source-auto-gain').checked = false;
|
||||
(document.getElementById('value-source-auto-gain') as HTMLInputElement).checked = false;
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
_setSlider('value-source-audio-min-value', 0);
|
||||
@@ -344,23 +345,23 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
// Adaptive defaults
|
||||
_populateScheduleUI([]);
|
||||
_populatePictureSourceDropdown('');
|
||||
document.getElementById('value-source-scene-behavior').value = 'complement';
|
||||
(document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value = 'complement';
|
||||
_setSlider('value-source-scene-sensitivity', 1.0);
|
||||
_setSlider('value-source-scene-smoothing', 0.3);
|
||||
_setSlider('value-source-adaptive-min-value', 0);
|
||||
_setSlider('value-source-adaptive-max-value', 1);
|
||||
// Daylight defaults
|
||||
_setSlider('value-source-daylight-speed', 1.0);
|
||||
document.getElementById('value-source-daylight-real-time').checked = false;
|
||||
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
|
||||
_setSlider('value-source-daylight-latitude', 50);
|
||||
_syncDaylightVSSpeedVisibility();
|
||||
_autoGenerateVSName();
|
||||
}
|
||||
|
||||
// Wire up auto-name triggers
|
||||
document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
|
||||
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
|
||||
(document.getElementById('value-source-waveform') as HTMLSelectElement).onchange = () => { _autoGenerateVSName(); _drawWaveformPreview((document.getElementById('value-source-waveform') as HTMLSelectElement).value); };
|
||||
(document.getElementById('value-source-mode') as HTMLSelectElement).onchange = () => _autoGenerateVSName();
|
||||
(document.getElementById('value-source-picture-source') as HTMLSelectElement).onchange = () => _autoGenerateVSName();
|
||||
|
||||
// Tags
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
@@ -376,22 +377,22 @@ export async function closeValueSourceModal() {
|
||||
}
|
||||
|
||||
export function onValueSourceTypeChange() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
|
||||
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
|
||||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
(document.getElementById('value-source-static-section') as HTMLElement).style.display = type === 'static' ? '' : 'none';
|
||||
(document.getElementById('value-source-animated-section') as HTMLElement).style.display = type === 'animated' ? '' : 'none';
|
||||
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview((document.getElementById('value-source-waveform') as HTMLSelectElement).value); }
|
||||
(document.getElementById('value-source-audio-section') as HTMLElement).style.display = type === 'audio' ? '' : 'none';
|
||||
if (type === 'audio') _ensureAudioModeIconSelect();
|
||||
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none';
|
||||
document.getElementById('value-source-daylight-section').style.display = type === 'daylight' ? '' : 'none';
|
||||
document.getElementById('value-source-adaptive-range-section').style.display =
|
||||
(document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
(document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none';
|
||||
(document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
|
||||
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
|
||||
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
||||
|
||||
// Populate audio dropdown when switching to audio type
|
||||
if (type === 'audio') {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
|
||||
if (select && select.options.length === 0) {
|
||||
_populateAudioSourceDropdown('');
|
||||
}
|
||||
@@ -412,18 +413,18 @@ export function onDaylightVSRealTimeChange() {
|
||||
}
|
||||
|
||||
function _syncDaylightVSSpeedVisibility() {
|
||||
const rt = document.getElementById('value-source-daylight-real-time').checked;
|
||||
document.getElementById('value-source-daylight-speed-group').style.display = rt ? 'none' : '';
|
||||
const rt = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
|
||||
(document.getElementById('value-source-daylight-speed-group') as HTMLElement).style.display = rt ? 'none' : '';
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveValueSource() {
|
||||
const id = document.getElementById('value-source-id').value;
|
||||
const name = document.getElementById('value-source-name').value.trim();
|
||||
const sourceType = document.getElementById('value-source-type').value;
|
||||
const description = document.getElementById('value-source-description').value.trim() || null;
|
||||
const errorEl = document.getElementById('value-source-error');
|
||||
const id = (document.getElementById('value-source-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('value-source-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('value-source-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('value-source-description') as HTMLInputElement).value.trim() || null;
|
||||
const errorEl = document.getElementById('value-source-error') as HTMLElement;
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('value_source.error.name_required');
|
||||
@@ -431,23 +432,23 @@ export async function saveValueSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };
|
||||
const payload: any = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'static') {
|
||||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||||
payload.value = parseFloat((document.getElementById('value-source-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'animated') {
|
||||
payload.waveform = document.getElementById('value-source-waveform').value;
|
||||
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
|
||||
payload.waveform = (document.getElementById('value-source-waveform') as HTMLSelectElement).value;
|
||||
payload.speed = parseFloat((document.getElementById('value-source-speed') as HTMLInputElement).value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-max-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'audio') {
|
||||
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
|
||||
payload.mode = document.getElementById('value-source-mode').value;
|
||||
payload.auto_gain = document.getElementById('value-source-auto-gain').checked;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').value);
|
||||
payload.audio_source_id = (document.getElementById('value-source-audio-source') as HTMLSelectElement).value;
|
||||
payload.mode = (document.getElementById('value-source-mode') as HTMLSelectElement).value;
|
||||
payload.auto_gain = (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked;
|
||||
payload.sensitivity = parseFloat((document.getElementById('value-source-sensitivity') as HTMLInputElement).value);
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-smoothing') as HTMLInputElement).value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-audio-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-audio-max-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'adaptive_time') {
|
||||
payload.schedule = _getScheduleFromUI();
|
||||
if (payload.schedule.length < 2) {
|
||||
@@ -455,21 +456,21 @@ export async function saveValueSource() {
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'adaptive_scene') {
|
||||
payload.picture_source_id = document.getElementById('value-source-picture-source').value;
|
||||
payload.scene_behavior = document.getElementById('value-source-scene-behavior').value;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||||
payload.picture_source_id = (document.getElementById('value-source-picture-source') as HTMLSelectElement).value;
|
||||
payload.scene_behavior = (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value;
|
||||
payload.sensitivity = parseFloat((document.getElementById('value-source-scene-sensitivity') as HTMLInputElement).value);
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-scene-smoothing') as HTMLInputElement).value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
||||
} else if (sourceType === 'daylight') {
|
||||
payload.speed = parseFloat(document.getElementById('value-source-daylight-speed').value);
|
||||
payload.use_real_time = document.getElementById('value-source-daylight-real-time').checked;
|
||||
payload.latitude = parseFloat(document.getElementById('value-source-daylight-latitude').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||||
payload.speed = parseFloat((document.getElementById('value-source-daylight-speed') as HTMLInputElement).value);
|
||||
payload.use_real_time = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
|
||||
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -488,7 +489,7 @@ export async function saveValueSource() {
|
||||
valueSourceModal.forceClose();
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
@@ -496,13 +497,13 @@ export async function saveValueSource() {
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
export async function editValueSource(sourceId) {
|
||||
export async function editValueSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('value_source.error.load'));
|
||||
const data = await resp.json();
|
||||
await showValueSourceModal(data);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@@ -510,7 +511,7 @@ export async function editValueSource(sourceId) {
|
||||
|
||||
// ── Clone ─────────────────────────────────────────────────────
|
||||
|
||||
export async function cloneValueSource(sourceId) {
|
||||
export async function cloneValueSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('value_source.error.load'));
|
||||
@@ -518,7 +519,7 @@ export async function cloneValueSource(sourceId) {
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showValueSourceModal(data);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
@@ -526,7 +527,7 @@ export async function cloneValueSource(sourceId) {
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteValueSource(sourceId) {
|
||||
export async function deleteValueSource(sourceId: any) {
|
||||
const confirmed = await showConfirm(t('value_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -539,7 +540,7 @@ export async function deleteValueSource(sourceId) {
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -548,16 +549,16 @@ export async function deleteValueSource(sourceId) {
|
||||
|
||||
const VS_HISTORY_SIZE = 200;
|
||||
|
||||
let _testVsWs = null;
|
||||
let _testVsAnimFrame = null;
|
||||
let _testVsLatest = null;
|
||||
let _testVsWs: WebSocket | null = null;
|
||||
let _testVsAnimFrame: number | null = null;
|
||||
let _testVsLatest: any = null;
|
||||
let _testVsHistory = [];
|
||||
let _testVsMinObserved = Infinity;
|
||||
let _testVsMaxObserved = -Infinity;
|
||||
|
||||
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
|
||||
|
||||
export function testValueSource(sourceId) {
|
||||
export function testValueSource(sourceId: any) {
|
||||
const statusEl = document.getElementById('vs-test-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = t('value_source.test.connecting');
|
||||
@@ -577,7 +578,7 @@ export function testValueSource(sourceId) {
|
||||
testVsModal.open();
|
||||
|
||||
// Size canvas to container
|
||||
const canvas = document.getElementById('vs-test-canvas');
|
||||
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
|
||||
_sizeVsCanvas(canvas);
|
||||
|
||||
// Connect WebSocket
|
||||
@@ -640,7 +641,7 @@ function _cleanupVsTest() {
|
||||
_testVsLatest = null;
|
||||
}
|
||||
|
||||
function _sizeVsCanvas(canvas) {
|
||||
function _sizeVsCanvas(canvas: HTMLCanvasElement) {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
@@ -657,7 +658,7 @@ function _renderVsTestLoop() {
|
||||
}
|
||||
|
||||
function _renderVsChart() {
|
||||
const canvas = document.getElementById('vs-test-canvas');
|
||||
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -734,7 +735,7 @@ function _renderVsChart() {
|
||||
|
||||
// ── Card rendering (used by streams.js) ───────────────────────
|
||||
|
||||
export function createValueSourceCard(src) {
|
||||
export function createValueSourceCard(src: ValueSource) {
|
||||
const icon = getValueSourceIcon(src.source_type);
|
||||
|
||||
let propsHtml = '';
|
||||
@@ -812,8 +813,8 @@ export function createValueSourceCard(src) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function _setSlider(id, value) {
|
||||
const slider = document.getElementById(id);
|
||||
function _setSlider(id: string, value: any) {
|
||||
const slider = document.getElementById(id) as HTMLInputElement;
|
||||
if (slider) {
|
||||
slider.value = value;
|
||||
const display = document.getElementById(id + '-display');
|
||||
@@ -821,10 +822,10 @@ function _setSlider(id, value) {
|
||||
}
|
||||
}
|
||||
|
||||
function _populateAudioSourceDropdown(selectedId) {
|
||||
const select = document.getElementById('value-source-audio-source');
|
||||
function _populateAudioSourceDropdown(selectedId: any) {
|
||||
const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedAudioSources.map(s => {
|
||||
select.innerHTML = _cachedAudioSources.map((s: any) => {
|
||||
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
|
||||
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
|
||||
}).join('');
|
||||
@@ -846,10 +847,10 @@ function _populateAudioSourceDropdown(selectedId) {
|
||||
|
||||
// ── Adaptive helpers ──────────────────────────────────────────
|
||||
|
||||
function _populatePictureSourceDropdown(selectedId) {
|
||||
const select = document.getElementById('value-source-picture-source');
|
||||
function _populatePictureSourceDropdown(selectedId: any) {
|
||||
const select = document.getElementById('value-source-picture-source') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedStreams.map(s =>
|
||||
select.innerHTML = _cachedStreams.map((s: any) =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
@@ -867,7 +868,7 @@ function _populatePictureSourceDropdown(selectedId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function addSchedulePoint(time = '', value = 1.0) {
|
||||
export function addSchedulePoint(time: string = '', value: number = 1.0) {
|
||||
const list = document.getElementById('value-source-schedule-list');
|
||||
if (!list) return;
|
||||
const row = document.createElement('div');
|
||||
@@ -886,14 +887,14 @@ function _getScheduleFromUI() {
|
||||
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
|
||||
const schedule = [];
|
||||
rows.forEach(row => {
|
||||
const time = row.querySelector('.schedule-time').value;
|
||||
const value = parseFloat(row.querySelector('.schedule-value').value);
|
||||
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
|
||||
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);
|
||||
if (time) schedule.push({ time, value });
|
||||
});
|
||||
return schedule;
|
||||
}
|
||||
|
||||
function _populateScheduleUI(schedule) {
|
||||
function _populateScheduleUI(schedule: any) {
|
||||
const list = document.getElementById('value-source-schedule-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
398
server/src/wled_controller/static/js/global.d.ts
vendored
Normal file
398
server/src/wled_controller/static/js/global.d.ts
vendored
Normal file
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Global type declarations for the WLED Screen Controller frontend.
|
||||
* Extends Window with dynamic properties used across modules.
|
||||
*/
|
||||
|
||||
declare class Chart {
|
||||
constructor(canvas: any, config: any);
|
||||
data: any;
|
||||
options: any;
|
||||
update(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
// ─── Auth (set by inline <script> in index.html) ───
|
||||
updateAuthUI: (() => void) | undefined;
|
||||
showApiKeyModal: ((msg: string | null, force?: boolean) => void) | undefined;
|
||||
|
||||
// ─── Core / state ───
|
||||
setApiKey: (key: string | null) => void;
|
||||
|
||||
// ─── Visual effects (called from inline <script>) ───
|
||||
_updateBgAnimAccent: (accent: string) => void;
|
||||
_updateBgAnimTheme: (dark: boolean) => void;
|
||||
_updateTabIndicator: () => void;
|
||||
|
||||
// ─── Core / UI ───
|
||||
toggleHint: (...args: any[]) => any;
|
||||
lockBody: () => void;
|
||||
unlockBody: () => void;
|
||||
closeLightbox: (...args: any[]) => void;
|
||||
showToast: (...args: any[]) => any;
|
||||
showConfirm: (...args: any[]) => any;
|
||||
closeConfirmModal: (...args: any[]) => any;
|
||||
openFullImageLightbox: (...args: any[]) => any;
|
||||
showOverlaySpinner: (...args: any[]) => any;
|
||||
hideOverlaySpinner: (...args: any[]) => any;
|
||||
|
||||
// ─── Core / API + i18n ───
|
||||
t: (key: string, ...args: any[]) => string;
|
||||
configureApiKey: (...args: any[]) => any;
|
||||
loadServerInfo: (...args: any[]) => any;
|
||||
loadDisplays: (...args: any[]) => any;
|
||||
changeLocale: (...args: any[]) => any;
|
||||
|
||||
// ─── Displays ───
|
||||
openDisplayPicker: (...args: any[]) => any;
|
||||
closeDisplayPicker: (...args: any[]) => any;
|
||||
selectDisplay: (...args: any[]) => any;
|
||||
formatDisplayLabel: (...args: any[]) => any;
|
||||
|
||||
// ─── Tutorials ───
|
||||
startCalibrationTutorial: (...args: any[]) => any;
|
||||
startDeviceTutorial: (...args: any[]) => any;
|
||||
startGettingStartedTutorial: (...args: any[]) => any;
|
||||
startDashboardTutorial: (...args: any[]) => any;
|
||||
startTargetsTutorial: (...args: any[]) => any;
|
||||
startSourcesTutorial: (...args: any[]) => any;
|
||||
startAutomationsTutorial: (...args: any[]) => any;
|
||||
closeTutorial: (...args: any[]) => any;
|
||||
tutorialNext: (...args: any[]) => any;
|
||||
tutorialPrev: (...args: any[]) => any;
|
||||
|
||||
// ─── Devices ───
|
||||
showSettings: (...args: any[]) => any;
|
||||
closeDeviceSettingsModal: (...args: any[]) => any;
|
||||
forceCloseDeviceSettingsModal: (...args: any[]) => any;
|
||||
saveDeviceSettings: (...args: any[]) => any;
|
||||
updateBrightnessLabel: (...args: any[]) => any;
|
||||
saveCardBrightness: (...args: any[]) => any;
|
||||
turnOffDevice: (...args: any[]) => any;
|
||||
pingDevice: (...args: any[]) => any;
|
||||
removeDevice: (...args: any[]) => any;
|
||||
loadDevices: (...args: any[]) => any;
|
||||
updateSettingsBaudFpsHint: (...args: any[]) => any;
|
||||
copyWsUrl: (...args: any[]) => any;
|
||||
cloneDevice: (...args: any[]) => any;
|
||||
|
||||
// ─── Dashboard ───
|
||||
loadDashboard: (...args: any[]) => any;
|
||||
stopUptimeTimer: (...args: any[]) => any;
|
||||
dashboardToggleAutomation: (...args: any[]) => any;
|
||||
dashboardStartTarget: (...args: any[]) => any;
|
||||
dashboardStopTarget: (...args: any[]) => any;
|
||||
dashboardStopAll: (...args: any[]) => any;
|
||||
dashboardPauseClock: (...args: any[]) => any;
|
||||
dashboardResumeClock: (...args: any[]) => any;
|
||||
dashboardResetClock: (...args: any[]) => any;
|
||||
toggleDashboardSection: (...args: any[]) => any;
|
||||
changeDashboardPollInterval: (...args: any[]) => any;
|
||||
startPerfPolling: (...args: any[]) => any;
|
||||
stopPerfPolling: (...args: any[]) => any;
|
||||
|
||||
// ─── Streams / Capture templates / PP templates / Audio templates / CSPT ───
|
||||
loadPictureSources: (...args: any[]) => any;
|
||||
switchStreamTab: (subTab: string) => void;
|
||||
showAddTemplateModal: (...args: any[]) => any;
|
||||
editTemplate: (...args: any[]) => any;
|
||||
closeTemplateModal: (...args: any[]) => any;
|
||||
saveTemplate: (...args: any[]) => any;
|
||||
deleteTemplate: (...args: any[]) => any;
|
||||
showTestTemplateModal: (...args: any[]) => any;
|
||||
closeTestTemplateModal: (...args: any[]) => any;
|
||||
onEngineChange: (...args: any[]) => any;
|
||||
runTemplateTest: (...args: any[]) => any;
|
||||
updateCaptureDuration: (...args: any[]) => any;
|
||||
showAddStreamModal: (...args: any[]) => any;
|
||||
editStream: (...args: any[]) => any;
|
||||
closeStreamModal: (...args: any[]) => any;
|
||||
saveStream: (...args: any[]) => any;
|
||||
deleteStream: (...args: any[]) => any;
|
||||
onStreamTypeChange: (...args: any[]) => any;
|
||||
onStreamDisplaySelected: (...args: any[]) => any;
|
||||
onTestDisplaySelected: (...args: any[]) => any;
|
||||
showTestStreamModal: (...args: any[]) => any;
|
||||
closeTestStreamModal: (...args: any[]) => any;
|
||||
updateStreamTestDuration: (...args: any[]) => any;
|
||||
runStreamTest: (...args: any[]) => any;
|
||||
showTestPPTemplateModal: (...args: any[]) => any;
|
||||
closeTestPPTemplateModal: (...args: any[]) => any;
|
||||
updatePPTestDuration: (...args: any[]) => any;
|
||||
runPPTemplateTest: (...args: any[]) => any;
|
||||
showAddPPTemplateModal: (...args: any[]) => any;
|
||||
editPPTemplate: (...args: any[]) => any;
|
||||
closePPTemplateModal: (...args: any[]) => any;
|
||||
savePPTemplate: (...args: any[]) => any;
|
||||
deletePPTemplate: (...args: any[]) => any;
|
||||
addFilterFromSelect: (...args: any[]) => any;
|
||||
toggleFilterExpand: (...args: any[]) => any;
|
||||
removeFilter: (...args: any[]) => any;
|
||||
moveFilter: (...args: any[]) => any;
|
||||
updateFilterOption: (...args: any[]) => any;
|
||||
renderModalFilterList: (...args: any[]) => any;
|
||||
cloneStream: (...args: any[]) => any;
|
||||
cloneCaptureTemplate: (...args: any[]) => any;
|
||||
clonePPTemplate: (...args: any[]) => any;
|
||||
showAddCSPTModal: (...args: any[]) => any;
|
||||
editCSPT: (...args: any[]) => any;
|
||||
closeCSPTModal: (...args: any[]) => any;
|
||||
saveCSPT: (...args: any[]) => any;
|
||||
deleteCSPT: (...args: any[]) => any;
|
||||
cloneCSPT: (...args: any[]) => any;
|
||||
csptAddFilterFromSelect: (...args: any[]) => any;
|
||||
csptToggleFilterExpand: (...args: any[]) => any;
|
||||
csptRemoveFilter: (...args: any[]) => any;
|
||||
csptUpdateFilterOption: (...args: any[]) => any;
|
||||
renderCSPTModalFilterList: (...args: any[]) => any;
|
||||
showAddAudioTemplateModal: (...args: any[]) => any;
|
||||
editAudioTemplate: (...args: any[]) => any;
|
||||
closeAudioTemplateModal: (...args: any[]) => any;
|
||||
saveAudioTemplate: (...args: any[]) => any;
|
||||
deleteAudioTemplate: (...args: any[]) => any;
|
||||
cloneAudioTemplate: (...args: any[]) => any;
|
||||
onAudioEngineChange: (...args: any[]) => any;
|
||||
showTestAudioTemplateModal: (...args: any[]) => any;
|
||||
closeTestAudioTemplateModal: (...args: any[]) => any;
|
||||
startAudioTemplateTest: (...args: any[]) => any;
|
||||
|
||||
// ─── KC Targets ───
|
||||
createKCTargetCard: (...args: any[]) => any;
|
||||
testKCTarget: (...args: any[]) => any;
|
||||
showKCEditor: (...args: any[]) => any;
|
||||
closeKCEditorModal: (...args: any[]) => any;
|
||||
forceCloseKCEditorModal: (...args: any[]) => any;
|
||||
saveKCEditor: (...args: any[]) => any;
|
||||
deleteKCTarget: (...args: any[]) => any;
|
||||
disconnectAllKCWebSockets: (...args: any[]) => any;
|
||||
updateKCBrightnessLabel: (...args: any[]) => any;
|
||||
saveKCBrightness: (...args: any[]) => any;
|
||||
cloneKCTarget: (...args: any[]) => any;
|
||||
|
||||
// ─── Pattern Templates ───
|
||||
createPatternTemplateCard: (...args: any[]) => any;
|
||||
showPatternTemplateEditor: (...args: any[]) => any;
|
||||
closePatternTemplateModal: (...args: any[]) => any;
|
||||
forceClosePatternTemplateModal: (...args: any[]) => any;
|
||||
savePatternTemplate: (...args: any[]) => any;
|
||||
deletePatternTemplate: (...args: any[]) => any;
|
||||
renderPatternRectList: (...args: any[]) => any;
|
||||
selectPatternRect: (...args: any[]) => any;
|
||||
updatePatternRect: (...args: any[]) => any;
|
||||
addPatternRect: (...args: any[]) => any;
|
||||
deleteSelectedPatternRect: (...args: any[]) => any;
|
||||
removePatternRect: (...args: any[]) => any;
|
||||
capturePatternBackground: (...args: any[]) => any;
|
||||
clonePatternTemplate: (...args: any[]) => any;
|
||||
|
||||
// ─── Automations ───
|
||||
loadAutomations: (...args: any[]) => any;
|
||||
openAutomationEditor: (...args: any[]) => any;
|
||||
closeAutomationEditorModal: (...args: any[]) => any;
|
||||
saveAutomationEditor: (...args: any[]) => any;
|
||||
addAutomationCondition: (...args: any[]) => any;
|
||||
toggleAutomationEnabled: (...args: any[]) => any;
|
||||
cloneAutomation: (...args: any[]) => any;
|
||||
deleteAutomation: (...args: any[]) => any;
|
||||
copyWebhookUrl: (...args: any[]) => any;
|
||||
|
||||
// ─── Scene Presets ───
|
||||
openScenePresetCapture: (...args: any[]) => any;
|
||||
editScenePreset: (...args: any[]) => any;
|
||||
saveScenePreset: (...args: any[]) => any;
|
||||
closeScenePresetEditor: (...args: any[]) => any;
|
||||
activateScenePreset: (...args: any[]) => any;
|
||||
recaptureScenePreset: (...args: any[]) => any;
|
||||
cloneScenePreset: (...args: any[]) => any;
|
||||
deleteScenePreset: (...args: any[]) => any;
|
||||
addSceneTarget: (...args: any[]) => any;
|
||||
removeSceneTarget: (...args: any[]) => any;
|
||||
|
||||
// ─── Device Discovery ───
|
||||
onDeviceTypeChanged: (...args: any[]) => any;
|
||||
updateBaudFpsHint: (...args: any[]) => any;
|
||||
onSerialPortFocus: (...args: any[]) => any;
|
||||
showAddDevice: (...args: any[]) => any;
|
||||
closeAddDeviceModal: (...args: any[]) => any;
|
||||
scanForDevices: (...args: any[]) => any;
|
||||
handleAddDevice: (...args: any[]) => any;
|
||||
|
||||
// ─── Targets ───
|
||||
loadTargetsTab: (...args: any[]) => any;
|
||||
switchTargetSubTab: (subTab: string) => void;
|
||||
showTargetEditor: (...args: any[]) => any;
|
||||
closeTargetEditorModal: (...args: any[]) => any;
|
||||
forceCloseTargetEditorModal: (...args: any[]) => any;
|
||||
saveTargetEditor: (...args: any[]) => any;
|
||||
startTargetProcessing: (...args: any[]) => any;
|
||||
stopTargetProcessing: (...args: any[]) => any;
|
||||
stopAllLedTargets: (...args: any[]) => any;
|
||||
stopAllKCTargets: (...args: any[]) => any;
|
||||
startTargetOverlay: (...args: any[]) => any;
|
||||
stopTargetOverlay: (...args: any[]) => any;
|
||||
deleteTarget: (...args: any[]) => any;
|
||||
cloneTarget: (...args: any[]) => any;
|
||||
toggleLedPreview: (...args: any[]) => any;
|
||||
disconnectAllLedPreviewWS: (...args: any[]) => any;
|
||||
|
||||
// ─── Color-Strip Sources ───
|
||||
showCSSEditor: (...args: any[]) => any;
|
||||
closeCSSEditorModal: (...args: any[]) => any;
|
||||
forceCSSEditorClose: (...args: any[]) => any;
|
||||
saveCSSEditor: (...args: any[]) => any;
|
||||
deleteColorStrip: (...args: any[]) => any;
|
||||
onCSSTypeChange: (...args: any[]) => any;
|
||||
onEffectTypeChange: (...args: any[]) => any;
|
||||
onCSSClockChange: (...args: any[]) => any;
|
||||
onAnimationTypeChange: (...args: any[]) => any;
|
||||
onDaylightRealTimeChange: (...args: any[]) => any;
|
||||
colorCycleAddColor: (...args: any[]) => any;
|
||||
colorCycleRemoveColor: (...args: any[]) => any;
|
||||
compositeAddLayer: (...args: any[]) => any;
|
||||
compositeRemoveLayer: (...args: any[]) => any;
|
||||
mappedAddZone: (...args: any[]) => any;
|
||||
mappedRemoveZone: (...args: any[]) => any;
|
||||
onAudioVizChange: (...args: any[]) => any;
|
||||
applyGradientPreset: (...args: any[]) => any;
|
||||
onGradientPresetChange: (...args: any[]) => any;
|
||||
promptAndSaveGradientPreset: (...args: any[]) => any;
|
||||
applyCustomGradientPreset: (...args: any[]) => any;
|
||||
deleteAndRefreshGradientPreset: (...args: any[]) => any;
|
||||
cloneColorStrip: (...args: any[]) => any;
|
||||
toggleCSSOverlay: (...args: any[]) => any;
|
||||
previewCSSFromEditor: (...args: any[]) => any;
|
||||
copyEndpointUrl: (...args: any[]) => any;
|
||||
onNotificationFilterModeChange: (...args: any[]) => any;
|
||||
notificationAddAppColor: (...args: any[]) => any;
|
||||
notificationRemoveAppColor: (...args: any[]) => any;
|
||||
testNotification: (...args: any[]) => any;
|
||||
showNotificationHistory: (...args: any[]) => any;
|
||||
closeNotificationHistory: (...args: any[]) => any;
|
||||
refreshNotificationHistory: (...args: any[]) => any;
|
||||
testColorStrip: (...args: any[]) => any;
|
||||
testCSPT: (...args: any[]) => any;
|
||||
closeTestCssSourceModal: (...args: any[]) => any;
|
||||
applyCssTestSettings: (...args: any[]) => any;
|
||||
fireCssTestNotification: (...args: any[]) => any;
|
||||
fireCssTestNotificationLayer: (...args: any[]) => any;
|
||||
|
||||
// ─── Audio Sources ───
|
||||
showAudioSourceModal: (...args: any[]) => any;
|
||||
closeAudioSourceModal: (...args: any[]) => any;
|
||||
saveAudioSource: (...args: any[]) => any;
|
||||
editAudioSource: (...args: any[]) => any;
|
||||
cloneAudioSource: (...args: any[]) => any;
|
||||
deleteAudioSource: (...args: any[]) => any;
|
||||
testAudioSource: (...args: any[]) => any;
|
||||
closeTestAudioSourceModal: (...args: any[]) => any;
|
||||
refreshAudioDevices: (...args: any[]) => any;
|
||||
|
||||
// ─── Value Sources ───
|
||||
showValueSourceModal: (...args: any[]) => any;
|
||||
closeValueSourceModal: (...args: any[]) => any;
|
||||
saveValueSource: (...args: any[]) => any;
|
||||
editValueSource: (...args: any[]) => any;
|
||||
cloneValueSource: (...args: any[]) => any;
|
||||
deleteValueSource: (...args: any[]) => any;
|
||||
onValueSourceTypeChange: (...args: any[]) => any;
|
||||
onDaylightVSRealTimeChange: (...args: any[]) => any;
|
||||
addSchedulePoint: (...args: any[]) => any;
|
||||
testValueSource: (...args: any[]) => any;
|
||||
closeTestValueSourceModal: (...args: any[]) => any;
|
||||
|
||||
// ─── Calibration ───
|
||||
showCalibration: (...args: any[]) => any;
|
||||
closeCalibrationModal: (...args: any[]) => any;
|
||||
forceCloseCalibrationModal: (...args: any[]) => any;
|
||||
saveCalibration: (...args: any[]) => any;
|
||||
updateOffsetSkipLock: (...args: any[]) => any;
|
||||
updateCalibrationPreview: (...args: any[]) => any;
|
||||
setStartPosition: (...args: any[]) => any;
|
||||
toggleEdgeInputs: (...args: any[]) => any;
|
||||
toggleDirection: (...args: any[]) => any;
|
||||
toggleTestEdge: (...args: any[]) => any;
|
||||
showCSSCalibration: (...args: any[]) => any;
|
||||
toggleCalibrationOverlay: (...args: any[]) => any;
|
||||
|
||||
// ─── Advanced Calibration ───
|
||||
showAdvancedCalibration: (...args: any[]) => any;
|
||||
closeAdvancedCalibration: (...args: any[]) => any;
|
||||
saveAdvancedCalibration: (...args: any[]) => any;
|
||||
addCalibrationLine: (...args: any[]) => any;
|
||||
removeCalibrationLine: (...args: any[]) => any;
|
||||
selectCalibrationLine: (...args: any[]) => any;
|
||||
moveCalibrationLine: (...args: any[]) => any;
|
||||
updateCalibrationLine: (...args: any[]) => any;
|
||||
resetCalibrationView: (...args: any[]) => any;
|
||||
|
||||
// ─── Graph Editor ───
|
||||
loadGraphEditor: (...args: any[]) => any;
|
||||
toggleGraphLegend: (...args: any[]) => any;
|
||||
toggleGraphMinimap: (...args: any[]) => any;
|
||||
toggleGraphFilter: (...args: any[]) => any;
|
||||
toggleGraphFilterTypes: (...args: any[]) => any;
|
||||
toggleGraphHelp: (...args: any[]) => any;
|
||||
graphUndo: (...args: any[]) => any;
|
||||
graphRedo: (...args: any[]) => any;
|
||||
graphFitAll: (...args: any[]) => any;
|
||||
graphZoomIn: (...args: any[]) => any;
|
||||
graphZoomOut: (...args: any[]) => any;
|
||||
graphRelayout: (...args: any[]) => any;
|
||||
graphToggleFullscreen: (...args: any[]) => any;
|
||||
graphAddEntity: (...args: any[]) => any;
|
||||
|
||||
// ─── Tabs / Navigation / Command Palette ───
|
||||
switchTab: (tab: string) => void;
|
||||
startAutoRefresh: (...args: any[]) => any;
|
||||
navigateToCard: (...args: any[]) => any;
|
||||
openCommandPalette: (...args: any[]) => any;
|
||||
closeCommandPalette: (...args: any[]) => any;
|
||||
|
||||
// ─── Settings ───
|
||||
openSettingsModal: (...args: any[]) => any;
|
||||
closeSettingsModal: (...args: any[]) => any;
|
||||
switchSettingsTab: (...args: any[]) => any;
|
||||
downloadBackup: (...args: any[]) => any;
|
||||
handleRestoreFileSelected: (...args: any[]) => any;
|
||||
saveAutoBackupSettings: (...args: any[]) => any;
|
||||
restoreSavedBackup: (...args: any[]) => any;
|
||||
downloadSavedBackup: (...args: any[]) => any;
|
||||
deleteSavedBackup: (...args: any[]) => any;
|
||||
restartServer: (...args: any[]) => any;
|
||||
saveMqttSettings: (...args: any[]) => any;
|
||||
loadApiKeysList: (...args: any[]) => any;
|
||||
downloadPartialExport: (...args: any[]) => any;
|
||||
handlePartialImportFileSelected: (...args: any[]) => any;
|
||||
connectLogViewer: (...args: any[]) => any;
|
||||
disconnectLogViewer: (...args: any[]) => any;
|
||||
clearLogViewer: (...args: any[]) => any;
|
||||
applyLogFilter: (...args: any[]) => any;
|
||||
openLogOverlay: (...args: any[]) => any;
|
||||
closeLogOverlay: (...args: any[]) => any;
|
||||
loadLogLevel: (...args: any[]) => any;
|
||||
setLogLevel: (...args: any[]) => any;
|
||||
saveExternalUrl: (...args: any[]) => any;
|
||||
getBaseOrigin: (...args: any[]) => any;
|
||||
|
||||
// ─── Overlay spinner internals ───
|
||||
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
|
||||
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;
|
||||
_overlayAbortController: AbortController | null;
|
||||
|
||||
// ─── Color picker ───
|
||||
_cpCallbacks: Record<string, (hex: string) => void> | undefined;
|
||||
_cpToggle: (id: string) => void;
|
||||
_cpPick: (id: string, hex: string) => void;
|
||||
_cpReset: (id: string, resetColor: string) => void;
|
||||
|
||||
// ─── Calibration internals (stored on window for cross-function state) ───
|
||||
edgeSpans: Record<string, { start: number; end: number }>;
|
||||
_calibrationResizeObserver: ResizeObserver | null;
|
||||
_calibrationResizeRaf: number | null;
|
||||
|
||||
// ─── Targets internals ───
|
||||
_targetAutoName: (...args: any[]) => any;
|
||||
|
||||
// ─── Dynamic loader functions (entity-events uses window[loaderName]()) ───
|
||||
[key: string]: any;
|
||||
}
|
||||
574
server/src/wled_controller/static/js/types.ts
Normal file
574
server/src/wled_controller/static/js/types.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Shared TypeScript interfaces matching the backend Pydantic API schemas.
|
||||
*
|
||||
* These mirror the JSON shapes returned by the REST API. Field names use
|
||||
* snake_case to match the JSON payloads — no camelCase transformation is done.
|
||||
*/
|
||||
|
||||
// ── Device ────────────────────────────────────────────────────
|
||||
|
||||
export type DeviceType =
|
||||
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
|
||||
| 'openrgb' | 'dmx' | 'espnow' | 'hue' | 'usbhid' | 'spi'
|
||||
| 'chroma' | 'gamesense';
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
device_type: DeviceType;
|
||||
led_count: number;
|
||||
enabled: boolean;
|
||||
baud_rate?: number;
|
||||
auto_shutdown: boolean;
|
||||
send_latency_ms: number;
|
||||
rgbw: boolean;
|
||||
zone_mode: string;
|
||||
capabilities: string[];
|
||||
tags: string[];
|
||||
dmx_protocol: string;
|
||||
dmx_start_universe: number;
|
||||
dmx_start_channel: number;
|
||||
espnow_peer_mac: string;
|
||||
espnow_channel: number;
|
||||
hue_username: string;
|
||||
hue_client_key: string;
|
||||
hue_entertainment_group_id: string;
|
||||
spi_speed_hz: number;
|
||||
spi_led_type: string;
|
||||
chroma_device_type: string;
|
||||
gamesense_device_type: string;
|
||||
default_css_processing_template_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Output Target ─────────────────────────────────────────────
|
||||
|
||||
export type TargetType = 'led' | 'key_colors';
|
||||
|
||||
export interface KeyColorsSettings {
|
||||
fps: number;
|
||||
interpolation_mode: string;
|
||||
smoothing: number;
|
||||
pattern_template_id: string;
|
||||
brightness: number;
|
||||
brightness_value_source_id: string;
|
||||
}
|
||||
|
||||
export interface OutputTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
target_type: TargetType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// LED target fields
|
||||
device_id?: string;
|
||||
color_strip_source_id?: string;
|
||||
brightness_value_source_id?: string;
|
||||
fps?: number;
|
||||
keepalive_interval?: number;
|
||||
state_check_interval?: number;
|
||||
min_brightness_threshold?: number;
|
||||
adaptive_fps?: boolean;
|
||||
protocol?: string;
|
||||
|
||||
// Key Colors target fields
|
||||
picture_source_id?: string;
|
||||
key_colors_settings?: KeyColorsSettings;
|
||||
}
|
||||
|
||||
// ── Color Strip Source ────────────────────────────────────────
|
||||
|
||||
export type CSSSourceType =
|
||||
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
|
||||
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
|
||||
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
||||
| 'candlelight' | 'processed';
|
||||
|
||||
export interface ColorStop {
|
||||
position: number;
|
||||
color: number[];
|
||||
color_right?: number[];
|
||||
}
|
||||
|
||||
export interface CompositeLayer {
|
||||
source_id: string;
|
||||
blend_mode: string;
|
||||
opacity: number;
|
||||
enabled: boolean;
|
||||
brightness_source_id?: string;
|
||||
processing_template_id?: string;
|
||||
}
|
||||
|
||||
export interface MappedZone {
|
||||
source_id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
export interface AnimationConfig {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface CalibrationLine {
|
||||
picture_source_id: string;
|
||||
edge: 'top' | 'right' | 'bottom' | 'left';
|
||||
led_count: number;
|
||||
span_start: number;
|
||||
span_end: number;
|
||||
reverse: boolean;
|
||||
border_width: number;
|
||||
}
|
||||
|
||||
export interface Calibration {
|
||||
mode: 'simple' | 'advanced';
|
||||
lines?: CalibrationLine[];
|
||||
layout?: 'clockwise' | 'counterclockwise';
|
||||
start_position?: 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
|
||||
offset?: number;
|
||||
leds_top?: number;
|
||||
leds_right?: number;
|
||||
leds_bottom?: number;
|
||||
leds_left?: number;
|
||||
span_top_start?: number;
|
||||
span_top_end?: number;
|
||||
span_right_start?: number;
|
||||
span_right_end?: number;
|
||||
span_bottom_start?: number;
|
||||
span_bottom_end?: number;
|
||||
span_left_start?: number;
|
||||
span_left_end?: number;
|
||||
skip_leds_start?: number;
|
||||
skip_leds_end?: number;
|
||||
border_width?: number;
|
||||
}
|
||||
|
||||
export interface ColorStripSource {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: CSSSourceType;
|
||||
led_count: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
overlay_active: boolean;
|
||||
clock_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Picture
|
||||
picture_source_id?: string;
|
||||
smoothing?: number;
|
||||
interpolation_mode?: string;
|
||||
calibration?: Calibration;
|
||||
|
||||
// Static
|
||||
color?: number[];
|
||||
|
||||
// Gradient
|
||||
stops?: ColorStop[];
|
||||
|
||||
// Color cycle
|
||||
colors?: number[][];
|
||||
|
||||
// Effect
|
||||
effect_type?: string;
|
||||
palette?: string;
|
||||
intensity?: number;
|
||||
scale?: number;
|
||||
mirror?: boolean;
|
||||
|
||||
// Composite
|
||||
layers?: CompositeLayer[];
|
||||
|
||||
// Mapped
|
||||
zones?: MappedZone[];
|
||||
|
||||
// Audio
|
||||
visualization_mode?: string;
|
||||
audio_source_id?: string;
|
||||
sensitivity?: number;
|
||||
color_peak?: number[];
|
||||
|
||||
// Animation
|
||||
animation?: AnimationConfig;
|
||||
speed?: number;
|
||||
|
||||
// API Input
|
||||
fallback_color?: number[];
|
||||
timeout?: number;
|
||||
|
||||
// Notification
|
||||
notification_effect?: string;
|
||||
duration_ms?: number;
|
||||
default_color?: string;
|
||||
app_colors?: Record<string, string>;
|
||||
app_filter_mode?: string;
|
||||
app_filter_list?: string[];
|
||||
os_listener?: boolean;
|
||||
|
||||
// Daylight
|
||||
use_real_time?: boolean;
|
||||
latitude?: number;
|
||||
|
||||
// Candlelight
|
||||
num_candles?: number;
|
||||
|
||||
// Processed
|
||||
input_source_id?: string;
|
||||
processing_template_id?: string;
|
||||
}
|
||||
|
||||
// ── Pattern Template ──────────────────────────────────────────
|
||||
|
||||
export interface KeyColorRectangle {
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PatternTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
rectangles: KeyColorRectangle[];
|
||||
tags: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Value Source ───────────────────────────────────────────────
|
||||
|
||||
export type ValueSourceType =
|
||||
| 'static' | 'animated' | 'audio'
|
||||
| 'adaptive_time' | 'adaptive_scene' | 'daylight';
|
||||
|
||||
export interface SchedulePoint {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ValueSource {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: ValueSourceType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Static
|
||||
value?: number;
|
||||
|
||||
// Animated
|
||||
waveform?: string;
|
||||
speed?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
|
||||
// Audio
|
||||
audio_source_id?: string;
|
||||
mode?: string;
|
||||
sensitivity?: number;
|
||||
smoothing?: number;
|
||||
auto_gain?: boolean;
|
||||
|
||||
// Adaptive
|
||||
schedule?: SchedulePoint[];
|
||||
picture_source_id?: string;
|
||||
scene_behavior?: string;
|
||||
|
||||
// Daylight
|
||||
use_real_time?: boolean;
|
||||
latitude?: number;
|
||||
}
|
||||
|
||||
// ── Audio Source ───────────────────────────────────────────────
|
||||
|
||||
export interface AudioSource {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: 'multichannel' | 'mono';
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Multichannel
|
||||
device_index?: number;
|
||||
is_loopback?: boolean;
|
||||
audio_template_id?: string;
|
||||
|
||||
// Mono
|
||||
audio_source_id?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// ── Picture Source ─────────────────────────────────────────────
|
||||
|
||||
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
|
||||
|
||||
export interface PictureSource {
|
||||
id: string;
|
||||
name: string;
|
||||
stream_type: PictureSourceType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Raw
|
||||
display_index?: number;
|
||||
capture_template_id?: string;
|
||||
target_fps?: number;
|
||||
|
||||
// Processed
|
||||
source_stream_id?: string;
|
||||
postprocessing_template_id?: string;
|
||||
|
||||
// Static image
|
||||
image_source?: string;
|
||||
|
||||
// Video
|
||||
url?: string;
|
||||
loop?: boolean;
|
||||
playback_speed?: number;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
resolution_limit?: number;
|
||||
clock_id?: string;
|
||||
}
|
||||
|
||||
// ── Scene Preset ──────────────────────────────────────────────
|
||||
|
||||
export interface TargetSnapshot {
|
||||
id?: string;
|
||||
target_id: string;
|
||||
running: boolean;
|
||||
color_strip_source_id: string;
|
||||
brightness_value_source_id: string;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
export interface ScenePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color?: string;
|
||||
targets: TargetSnapshot[];
|
||||
order: number;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Sync Clock ────────────────────────────────────────────────
|
||||
|
||||
export interface SyncClock {
|
||||
id: string;
|
||||
name: string;
|
||||
speed: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
is_running: boolean;
|
||||
elapsed_time: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Automation ────────────────────────────────────────────────
|
||||
|
||||
export type ConditionType =
|
||||
| 'always' | 'application' | 'time_of_day' | 'system_idle'
|
||||
| 'display_state' | 'mqtt' | 'webhook' | 'startup';
|
||||
|
||||
export interface AutomationCondition {
|
||||
condition_type: ConditionType;
|
||||
apps?: string[];
|
||||
match_type?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
idle_minutes?: number;
|
||||
when_idle?: boolean;
|
||||
state?: string;
|
||||
topic?: string;
|
||||
payload?: string;
|
||||
match_mode?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
condition_logic: 'or' | 'and';
|
||||
conditions: AutomationCondition[];
|
||||
scene_preset_id?: string;
|
||||
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
|
||||
deactivation_scene_preset_id?: string;
|
||||
tags: string[];
|
||||
webhook_url?: string;
|
||||
is_active: boolean;
|
||||
last_activated_at?: string;
|
||||
last_deactivated_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Templates ─────────────────────────────────────────────────
|
||||
|
||||
export interface FilterInstance {
|
||||
filter_id: string;
|
||||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CaptureTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
engine_type: string;
|
||||
engine_config: Record<string, any>;
|
||||
tags: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PostprocessingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: FilterInstance[];
|
||||
tags: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ColorStripProcessingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: FilterInstance[];
|
||||
tags: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AudioTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
engine_type: string;
|
||||
engine_config: Record<string, any>;
|
||||
tags: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Filter Definition (from /filters endpoint) ────────────────
|
||||
|
||||
export interface FilterOptionDef {
|
||||
type: string;
|
||||
default?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
choices?: string[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FilterDef {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
options: Record<string, FilterOptionDef>;
|
||||
}
|
||||
|
||||
// ── Engine Info (from /capture-engines, /audio-engines) ───────
|
||||
|
||||
export interface EngineInfo {
|
||||
type: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
has_own_displays?: boolean;
|
||||
default_config?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────
|
||||
|
||||
export interface Display {
|
||||
index: number;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
is_primary: boolean;
|
||||
}
|
||||
|
||||
// ── API List Response Wrappers ────────────────────────────────
|
||||
|
||||
export interface DeviceListResponse {
|
||||
devices: Device[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface OutputTargetListResponse {
|
||||
targets: OutputTarget[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ColorStripSourceListResponse {
|
||||
sources: ColorStripSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PatternTemplateListResponse {
|
||||
templates: PatternTemplate[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ValueSourceListResponse {
|
||||
sources: ValueSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AudioSourceListResponse {
|
||||
sources: AudioSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PictureSourceListResponse {
|
||||
streams: PictureSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ScenePresetListResponse {
|
||||
presets: ScenePreset[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SyncClockListResponse {
|
||||
clocks: SyncClock[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AutomationListResponse {
|
||||
automations: Automation[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Component Option Types (re-exported from authoritative sources) ───
|
||||
|
||||
export type { IconSelectItem, IconSelectOpts } from './core/icon-select.ts';
|
||||
export type { EntitySelectOpts } from './core/entity-palette.ts';
|
||||
export type { BulkAction, CardItem, CardSectionOpts } from './core/card-sections.ts';
|
||||
export type { FilterListOpts } from './core/filter-list.ts';
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": "src/wled_controller/static",
|
||||
"rootDir": "src/wled_controller/static"
|
||||
},
|
||||
"include": ["src/wled_controller/static/js/**/*.ts"],
|
||||
"exclude": ["node_modules", "src/wled_controller/static/dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user