cf019318a6
Validate / validate (push) Failing after 8s
- Add close X button to all modal headers (acts as Cancel) - Replace Cancel/Save labels with icon buttons (✕/✓) - Remove header/footer separator lines, reduce spacing - Fix canvas re-render on resize via ResizeObserver - Move calibration hint to top as section-tip - Increase toggle zones to 16px, make borders more visible - Differentiate min/max ticks (long) from intermediate (short) - Sync toggle zones and ticks with span position - Fix span handle z-index to stay above LED input - Add total LED label click to toggle edge input visibility - Remove corner icon scale on hover - Direction arrows fixed at full-edge midpoint (unaffected by span) - Span bars fill full edge area with 2px border radius Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1835 lines
67 KiB
JavaScript
1835 lines
67 KiB
JavaScript
const API_BASE = '/api/v1';
|
||
let refreshInterval = null;
|
||
let apiKey = null;
|
||
|
||
// Track logged errors to avoid console spam
|
||
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||
|
||
// Calibration test mode state
|
||
const calibrationTestState = {}; // deviceId -> Set of active edge names
|
||
|
||
// Modal dirty tracking - stores initial values when modals open
|
||
let settingsInitialValues = {};
|
||
let calibrationInitialValues = {};
|
||
const EDGE_TEST_COLORS = {
|
||
top: [255, 0, 0],
|
||
right: [0, 255, 0],
|
||
bottom: [0, 100, 255],
|
||
left: [255, 255, 0]
|
||
};
|
||
|
||
// Modal body lock helpers - prevent layout jump when scrollbar disappears
|
||
function lockBody() {
|
||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||
document.body.style.paddingRight = scrollbarWidth + 'px';
|
||
document.body.classList.add('modal-open');
|
||
}
|
||
|
||
function unlockBody() {
|
||
document.body.classList.remove('modal-open');
|
||
document.body.style.paddingRight = '';
|
||
}
|
||
|
||
// Locale management
|
||
let currentLocale = 'en';
|
||
let translations = {};
|
||
const supportedLocales = {
|
||
'en': 'English',
|
||
'ru': 'Русский'
|
||
};
|
||
|
||
// Minimal inline fallback for critical UI elements
|
||
const fallbackTranslations = {
|
||
'app.title': 'WLED Screen Controller',
|
||
'auth.placeholder': 'Enter your API key...',
|
||
'auth.button.login': 'Login'
|
||
};
|
||
|
||
// Translation function
|
||
function t(key, params = {}) {
|
||
let text = translations[key] || fallbackTranslations[key] || key;
|
||
|
||
// Replace parameters like {name}, {value}, etc.
|
||
Object.keys(params).forEach(param => {
|
||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||
});
|
||
|
||
return text;
|
||
}
|
||
|
||
// Load translation file
|
||
async function loadTranslations(locale) {
|
||
try {
|
||
const response = await fetch(`/static/locales/${locale}.json`);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load ${locale}.json`);
|
||
}
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error(`Error loading translations for ${locale}:`, error);
|
||
// Fallback to English if loading fails
|
||
if (locale !== 'en') {
|
||
return await loadTranslations('en');
|
||
}
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// Detect browser locale
|
||
function detectBrowserLocale() {
|
||
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
||
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
|
||
|
||
// Only return if we support it
|
||
return supportedLocales[langCode] ? langCode : 'en';
|
||
}
|
||
|
||
// Initialize locale
|
||
async function initLocale() {
|
||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||
await setLocale(savedLocale);
|
||
}
|
||
|
||
// Set locale
|
||
async function setLocale(locale) {
|
||
if (!supportedLocales[locale]) {
|
||
locale = 'en';
|
||
}
|
||
|
||
// Load translations for the locale
|
||
translations = await loadTranslations(locale);
|
||
|
||
currentLocale = locale;
|
||
document.documentElement.setAttribute('data-locale', locale);
|
||
document.documentElement.setAttribute('lang', locale);
|
||
localStorage.setItem('locale', locale);
|
||
|
||
// Update all text
|
||
updateAllText();
|
||
|
||
// Update locale select dropdown (if visible)
|
||
updateLocaleSelect();
|
||
}
|
||
|
||
// Change locale from dropdown
|
||
function changeLocale() {
|
||
const select = document.getElementById('locale-select');
|
||
const newLocale = select.value;
|
||
if (newLocale && newLocale !== currentLocale) {
|
||
localStorage.setItem('locale', newLocale);
|
||
setLocale(newLocale);
|
||
}
|
||
}
|
||
|
||
// Update locale select dropdown
|
||
function updateLocaleSelect() {
|
||
const select = document.getElementById('locale-select');
|
||
if (select) {
|
||
select.value = currentLocale;
|
||
}
|
||
}
|
||
|
||
// Update all text on page
|
||
function updateAllText() {
|
||
// Update all elements with data-i18n attribute
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-placeholder attribute
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-placeholder');
|
||
el.placeholder = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-title attribute
|
||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-title');
|
||
el.title = t(key);
|
||
});
|
||
|
||
// Re-render dynamic content with new translations
|
||
if (apiKey) {
|
||
loadDisplays();
|
||
loadDevices();
|
||
}
|
||
}
|
||
|
||
// Initialize app
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Initialize locale first
|
||
await initLocale();
|
||
|
||
// Show content now that translations are loaded
|
||
document.body.style.visibility = 'visible';
|
||
|
||
// Restore active tab
|
||
initTabs();
|
||
|
||
// Load API key from localStorage
|
||
apiKey = localStorage.getItem('wled_api_key');
|
||
|
||
// Setup form handler
|
||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||
|
||
// Show modal if no API key is stored
|
||
if (!apiKey) {
|
||
// Wait for modal functions to be defined
|
||
setTimeout(() => {
|
||
if (typeof showApiKeyModal === 'function') {
|
||
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
|
||
}
|
||
}, 100);
|
||
return; // Don't load data yet
|
||
}
|
||
|
||
// User is logged in, load data
|
||
loadServerInfo();
|
||
loadDisplays();
|
||
loadDevices();
|
||
|
||
// Start auto-refresh
|
||
startAutoRefresh();
|
||
});
|
||
|
||
// Helper function to add auth header if needed
|
||
function getHeaders() {
|
||
const headers = {
|
||
'Content-Type': 'application/json'
|
||
};
|
||
|
||
if (apiKey) {
|
||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
// Handle 401 errors by showing login modal
|
||
function handle401Error() {
|
||
// Clear invalid API key
|
||
localStorage.removeItem('wled_api_key');
|
||
apiKey = null;
|
||
|
||
// Stop auto-refresh to prevent repeated 401 errors
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
refreshInterval = null;
|
||
}
|
||
|
||
if (typeof updateAuthUI === 'function') {
|
||
updateAuthUI();
|
||
}
|
||
|
||
if (typeof showApiKeyModal === 'function') {
|
||
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
|
||
} else {
|
||
showToast('Authentication failed. Please reload the page and login.', 'error');
|
||
}
|
||
}
|
||
|
||
// Configure API key
|
||
function configureApiKey() {
|
||
const currentKey = localStorage.getItem('wled_api_key');
|
||
const message = currentKey
|
||
? 'Current API key is set. Enter new key to update or leave blank to remove:'
|
||
: 'Enter your API key:';
|
||
|
||
const key = prompt(message);
|
||
|
||
if (key === null) {
|
||
return; // Cancelled
|
||
}
|
||
|
||
if (key === '') {
|
||
localStorage.removeItem('wled_api_key');
|
||
apiKey = null;
|
||
document.getElementById('api-key-btn').style.display = 'none';
|
||
showToast('API key removed', 'info');
|
||
} else {
|
||
localStorage.setItem('wled_api_key', key);
|
||
apiKey = key;
|
||
document.getElementById('api-key-btn').style.display = 'inline-block';
|
||
showToast('API key updated', 'success');
|
||
}
|
||
|
||
// Reload data with new key
|
||
loadServerInfo();
|
||
loadDisplays();
|
||
loadDevices();
|
||
}
|
||
|
||
// Server info
|
||
async function loadServerInfo() {
|
||
try {
|
||
const response = await fetch('/health');
|
||
const data = await response.json();
|
||
|
||
document.getElementById('version-number').textContent = `v${data.version}`;
|
||
document.getElementById('server-status').textContent = '●';
|
||
document.getElementById('server-status').className = 'status-badge online';
|
||
} catch (error) {
|
||
console.error('Failed to load server info:', error);
|
||
document.getElementById('server-status').className = 'status-badge offline';
|
||
showToast(t('server.offline'), 'error');
|
||
}
|
||
}
|
||
|
||
// Load displays
|
||
async function loadDisplays() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/config/displays`, {
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
const container = document.getElementById('displays-list');
|
||
|
||
if (!data.displays || data.displays.length === 0) {
|
||
container.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||
document.getElementById('display-layout-canvas').innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||
return;
|
||
}
|
||
|
||
// Cache and render visual layout
|
||
_cachedDisplays = data.displays;
|
||
renderDisplayLayout(data.displays);
|
||
} catch (error) {
|
||
console.error('Failed to load displays:', error);
|
||
document.getElementById('displays-list').innerHTML =
|
||
`<div class="loading">${t('displays.failed')}</div>`;
|
||
document.getElementById('display-layout-canvas').innerHTML =
|
||
`<div class="loading">${t('displays.failed')}</div>`;
|
||
}
|
||
}
|
||
|
||
let _cachedDisplays = null;
|
||
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
|
||
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
||
localStorage.setItem('activeTab', name);
|
||
if (name === 'displays' && _cachedDisplays) {
|
||
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
|
||
}
|
||
}
|
||
|
||
function initTabs() {
|
||
const saved = localStorage.getItem('activeTab');
|
||
if (saved && document.getElementById(`tab-${saved}`)) {
|
||
switchTab(saved);
|
||
}
|
||
}
|
||
|
||
function renderDisplayLayout(displays) {
|
||
const canvas = document.getElementById('display-layout-canvas');
|
||
|
||
if (!displays || displays.length === 0) {
|
||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||
return;
|
||
}
|
||
|
||
// Calculate bounding box for all displays
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
|
||
displays.forEach(display => {
|
||
minX = Math.min(minX, display.x);
|
||
minY = Math.min(minY, display.y);
|
||
maxX = Math.max(maxX, display.x + display.width);
|
||
maxY = Math.max(maxY, display.y + display.height);
|
||
});
|
||
|
||
const totalWidth = maxX - minX;
|
||
const totalHeight = maxY - minY;
|
||
|
||
// Scale factor to fit in canvas (respect available width, maintain aspect ratio)
|
||
const availableWidth = canvas.clientWidth - 60; // account for padding
|
||
const maxCanvasHeight = 350;
|
||
const scaleX = availableWidth / totalWidth;
|
||
const scaleY = maxCanvasHeight / totalHeight;
|
||
const scale = Math.min(scaleX, scaleY);
|
||
|
||
const canvasWidth = totalWidth * scale;
|
||
const canvasHeight = totalHeight * scale;
|
||
|
||
// Create display elements
|
||
const displayElements = displays.map(display => {
|
||
const left = (display.x - minX) * scale;
|
||
const top = (display.y - minY) * scale;
|
||
const width = display.width * scale;
|
||
const height = display.height * scale;
|
||
|
||
return `
|
||
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
|
||
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
|
||
title="${display.name}\n${display.width}×${display.height}\nPosition: (${display.x}, ${display.y})">
|
||
<div class="layout-position-label">(${display.x}, ${display.y})</div>
|
||
<div class="layout-index-label">#${display.index}</div>
|
||
<div class="layout-display-label">
|
||
<strong>${display.name}</strong>
|
||
<small>${display.width}×${display.height}</small>
|
||
<small>${display.refresh_rate}Hz</small>
|
||
</div>
|
||
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
canvas.innerHTML = `
|
||
<div class="layout-container" style="width: ${canvasWidth}px; height: ${canvasHeight}px; margin: 0 auto; position: relative;">
|
||
${displayElements}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Load devices
|
||
async function loadDevices() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices`, {
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const devices = data.devices || [];
|
||
|
||
const container = document.getElementById('devices-list');
|
||
|
||
if (!devices || devices.length === 0) {
|
||
container.innerHTML = `<div class="card add-device-card" onclick="showAddDevice()">
|
||
<div class="add-device-icon">+</div>
|
||
<div class="add-device-label">${t('devices.add')}</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Fetch state for each device
|
||
const devicesWithState = await Promise.all(
|
||
devices.map(async (device) => {
|
||
try {
|
||
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
|
||
headers: getHeaders()
|
||
});
|
||
const state = await stateResponse.json();
|
||
|
||
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
|
||
headers: getHeaders()
|
||
});
|
||
const metrics = await metricsResponse.json();
|
||
|
||
// Log device errors only when they change (avoid console spam)
|
||
const deviceKey = device.id;
|
||
const lastLogged = loggedErrors.get(deviceKey);
|
||
const hasNewErrors = !lastLogged ||
|
||
lastLogged.errorCount !== metrics.errors_count ||
|
||
lastLogged.lastError !== metrics.last_error;
|
||
|
||
if (metrics.errors_count > 0 && hasNewErrors) {
|
||
console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`);
|
||
|
||
// Log recent errors from state
|
||
if (state.errors && state.errors.length > 0) {
|
||
console.error('Recent errors:');
|
||
state.errors.forEach((error, index) => {
|
||
console.error(` ${index + 1}. ${error}`);
|
||
});
|
||
}
|
||
|
||
// Log last error from metrics
|
||
if (metrics.last_error) {
|
||
console.error('Last error:', metrics.last_error);
|
||
}
|
||
|
||
// Log full state and metrics for debugging
|
||
console.log('Full state:', state);
|
||
console.log('Full metrics:', metrics);
|
||
|
||
// Update tracking
|
||
loggedErrors.set(deviceKey, {
|
||
errorCount: metrics.errors_count,
|
||
lastError: metrics.last_error
|
||
});
|
||
} else if (metrics.errors_count === 0 && lastLogged) {
|
||
// Clear tracking when errors are resolved
|
||
console.log(`[Device: ${device.name || device.id}] Errors cleared`);
|
||
loggedErrors.delete(deviceKey);
|
||
}
|
||
|
||
return { ...device, state, metrics };
|
||
} catch (error) {
|
||
console.error(`Failed to load state for device ${device.id}:`, error);
|
||
return device;
|
||
}
|
||
})
|
||
);
|
||
|
||
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('')
|
||
+ `<div class="card add-device-card" onclick="showAddDevice()">
|
||
<div class="add-device-icon">+</div>
|
||
<div class="add-device-label">${t('devices.add')}</div>
|
||
</div>`;
|
||
|
||
// Update footer WLED Web UI link with first device's URL
|
||
const webuiLink = document.querySelector('.wled-webui-link');
|
||
if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) {
|
||
webuiLink.href = devicesWithState[0].url;
|
||
webuiLink.target = '_blank';
|
||
webuiLink.rel = 'noopener';
|
||
}
|
||
|
||
// Attach event listeners
|
||
devicesWithState.forEach(device => {
|
||
attachDeviceListeners(device.id);
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load devices:', error);
|
||
document.getElementById('devices-list').innerHTML =
|
||
`<div class="loading">${t('devices.failed')}</div>`;
|
||
}
|
||
}
|
||
|
||
function createDeviceCard(device) {
|
||
const state = device.state || {};
|
||
const metrics = device.metrics || {};
|
||
const settings = device.settings || {};
|
||
|
||
const isProcessing = state.processing || false;
|
||
const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100);
|
||
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
|
||
const status = isProcessing ? 'processing' : 'idle';
|
||
|
||
// WLED device health indicator
|
||
const wledOnline = state.wled_online || false;
|
||
const wledLatency = state.wled_latency_ms;
|
||
const wledName = state.wled_name;
|
||
const wledVersion = state.wled_version;
|
||
const wledLastChecked = state.wled_last_checked;
|
||
|
||
let healthClass, healthTitle, healthLabel;
|
||
if (wledLastChecked === null || wledLastChecked === undefined) {
|
||
healthClass = 'health-unknown';
|
||
healthTitle = t('device.health.checking');
|
||
healthLabel = '';
|
||
} else if (wledOnline) {
|
||
healthClass = 'health-online';
|
||
healthTitle = `${t('device.health.online')}`;
|
||
if (wledName) healthTitle += ` - ${wledName}`;
|
||
if (wledVersion) healthTitle += ` v${wledVersion}`;
|
||
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
|
||
healthLabel = '';
|
||
} else {
|
||
healthClass = 'health-offline';
|
||
healthTitle = t('device.health.offline');
|
||
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
|
||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||
}
|
||
|
||
const displayIndex = settings.display_index !== undefined ? settings.display_index : 0;
|
||
const ledCount = state.wled_led_count || device.led_count;
|
||
|
||
return `
|
||
<div class="card" data-device-id="${device.id}">
|
||
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">✕</button>
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||
${device.name || device.id}
|
||
${healthLabel}
|
||
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="card-subtitle">
|
||
${wledVersion ? `<span class="card-meta">v${wledVersion}</span>` : ''}
|
||
<span class="card-meta" title="${t('device.display')}">🖥️ ${displayIndex}</span>
|
||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||
<span class="card-meta" title="${state.wled_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.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="info-row">
|
||
<span class="info-label">${t('device.url')}</span>
|
||
<span class="info-value">${device.url || 'N/A'}</span>
|
||
</div>
|
||
${isProcessing ? `
|
||
<div class="metrics-grid">
|
||
<div class="metric">
|
||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${state.fps_target || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.errors')}</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="brightness-control">
|
||
<input type="range" class="brightness-slider" min="0" max="100"
|
||
value="${brightnessPercent}"
|
||
oninput="updateBrightnessLabel('${device.id}', this.value)"
|
||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||
title="${brightnessPercent}%">
|
||
</div>
|
||
<div class="card-actions">
|
||
${isProcessing ? `
|
||
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}">
|
||
⏹️
|
||
</button>
|
||
` : `
|
||
<button class="btn btn-icon btn-primary" onclick="startProcessing('${device.id}')" title="${t('device.button.start')}">
|
||
▶️
|
||
</button>
|
||
`}
|
||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||
⚙️
|
||
</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||
📐
|
||
</button>
|
||
${device.url ? `
|
||
<a class="btn btn-icon btn-secondary" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}">
|
||
🌐
|
||
</a>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachDeviceListeners(deviceId) {
|
||
// Add any specific event listeners here if needed
|
||
}
|
||
|
||
// Device actions
|
||
async function startProcessing(deviceId) {
|
||
console.log(`[Device: ${deviceId}] Starting processing...`);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
|
||
method: 'POST',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
console.log(`[Device: ${deviceId}] Processing started successfully`);
|
||
showToast('Processing started', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
console.error(`[Device: ${deviceId}] Failed to start:`, error);
|
||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Device: ${deviceId}] Failed to start processing:`, error);
|
||
showToast('Failed to start processing', 'error');
|
||
}
|
||
}
|
||
|
||
async function stopProcessing(deviceId) {
|
||
console.log(`[Device: ${deviceId}] Stopping processing...`);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
|
||
method: 'POST',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
console.log(`[Device: ${deviceId}] Processing stopped successfully`);
|
||
showToast('Processing stopped', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
console.error(`[Device: ${deviceId}] Failed to stop:`, error);
|
||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Device: ${deviceId}] Failed to stop processing:`, error);
|
||
showToast('Failed to stop processing', 'error');
|
||
}
|
||
}
|
||
|
||
async function removeDevice(deviceId) {
|
||
const confirmed = await showConfirm(t('device.remove.confirm'));
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'DELETE',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
showToast('Device removed', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
showToast(`Failed to remove: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to remove device:', error);
|
||
showToast('Failed to remove device', 'error');
|
||
}
|
||
}
|
||
|
||
async function showSettings(deviceId) {
|
||
try {
|
||
// Fetch device data and displays in parallel
|
||
const [deviceResponse, displaysResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||
]);
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
showToast('Failed to load device settings', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await deviceResponse.json();
|
||
|
||
// Populate display index select
|
||
const displaySelect = document.getElementById('settings-display-index');
|
||
displaySelect.innerHTML = '';
|
||
if (displaysResponse.ok) {
|
||
const displaysData = await displaysResponse.json();
|
||
(displaysData.displays || []).forEach(d => {
|
||
const opt = document.createElement('option');
|
||
opt.value = d.index;
|
||
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
|
||
displaySelect.appendChild(opt);
|
||
});
|
||
}
|
||
if (displaySelect.options.length === 0) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '0';
|
||
opt.textContent = '0';
|
||
displaySelect.appendChild(opt);
|
||
}
|
||
displaySelect.value = String(device.settings.display_index ?? 0);
|
||
|
||
// Populate other fields
|
||
document.getElementById('settings-device-id').value = device.id;
|
||
document.getElementById('settings-device-name').value = device.name;
|
||
document.getElementById('settings-device-url').value = device.url;
|
||
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
|
||
|
||
// Snapshot initial values for dirty checking
|
||
settingsInitialValues = {
|
||
name: device.name,
|
||
url: device.url,
|
||
display_index: String(device.settings.display_index ?? 0),
|
||
state_check_interval: String(device.settings.state_check_interval || 30),
|
||
};
|
||
|
||
// Show modal
|
||
const modal = document.getElementById('device-settings-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
|
||
// Focus first input
|
||
setTimeout(() => {
|
||
document.getElementById('settings-device-name').focus();
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load device settings:', error);
|
||
showToast('Failed to load device settings', 'error');
|
||
}
|
||
}
|
||
|
||
function isSettingsDirty() {
|
||
return (
|
||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
|
||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||
);
|
||
}
|
||
|
||
function forceCloseDeviceSettingsModal() {
|
||
const modal = document.getElementById('device-settings-modal');
|
||
const error = document.getElementById('settings-error');
|
||
modal.style.display = 'none';
|
||
error.style.display = 'none';
|
||
unlockBody();
|
||
settingsInitialValues = {};
|
||
}
|
||
|
||
async function closeDeviceSettingsModal() {
|
||
if (isSettingsDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseDeviceSettingsModal();
|
||
}
|
||
|
||
async function saveDeviceSettings() {
|
||
const deviceId = document.getElementById('settings-device-id').value;
|
||
const name = document.getElementById('settings-device-name').value.trim();
|
||
const url = document.getElementById('settings-device-url').value.trim();
|
||
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
|
||
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
||
const error = document.getElementById('settings-error');
|
||
|
||
// Validation
|
||
if (!name || !url) {
|
||
error.textContent = 'Please fill in all fields correctly';
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Update device info (name, url)
|
||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ name, url })
|
||
});
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
const errorData = await deviceResponse.json();
|
||
error.textContent = `Failed to update device: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Update settings (health check interval)
|
||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ display_index, state_check_interval })
|
||
});
|
||
|
||
if (settingsResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (settingsResponse.ok) {
|
||
showToast('Device settings updated', 'success');
|
||
forceCloseDeviceSettingsModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await settingsResponse.json();
|
||
error.textContent = `Failed to update settings: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save device settings:', err);
|
||
error.textContent = 'Failed to save settings';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Card brightness controls
|
||
function updateBrightnessLabel(deviceId, value) {
|
||
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
|
||
if (slider) slider.title = value + '%';
|
||
}
|
||
|
||
async function saveCardBrightness(deviceId, value) {
|
||
const brightness = parseInt(value) / 100.0;
|
||
try {
|
||
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ brightness })
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to update brightness:', err);
|
||
showToast('Failed to update brightness', 'error');
|
||
}
|
||
}
|
||
|
||
// Add device modal
|
||
function showAddDevice() {
|
||
const modal = document.getElementById('add-device-modal');
|
||
const form = document.getElementById('add-device-form');
|
||
const error = document.getElementById('add-device-error');
|
||
form.reset();
|
||
error.style.display = 'none';
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||
}
|
||
|
||
function closeAddDeviceModal() {
|
||
const modal = document.getElementById('add-device-modal');
|
||
modal.style.display = 'none';
|
||
unlockBody();
|
||
}
|
||
|
||
async function handleAddDevice(event) {
|
||
event.preventDefault();
|
||
|
||
const name = document.getElementById('device-name').value.trim();
|
||
const url = document.getElementById('device-url').value.trim();
|
||
const error = document.getElementById('add-device-error');
|
||
|
||
if (!name || !url) {
|
||
error.textContent = 'Please fill in all fields';
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices`, {
|
||
method: 'POST',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ name, url })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
console.log('Device added successfully:', result);
|
||
showToast('Device added successfully', 'success');
|
||
closeAddDeviceModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await response.json();
|
||
console.error('Failed to add device:', errorData);
|
||
error.textContent = `Failed to add device: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add device:', err);
|
||
showToast('Failed to add device', 'error');
|
||
}
|
||
}
|
||
|
||
// Auto-refresh
|
||
function startAutoRefresh() {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
|
||
refreshInterval = setInterval(() => {
|
||
// Only refresh if user is authenticated
|
||
if (apiKey) {
|
||
loadDevices();
|
||
}
|
||
}, 2000); // Refresh every 2 seconds
|
||
}
|
||
|
||
// Toast notifications
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type} show`;
|
||
|
||
setTimeout(() => {
|
||
toast.className = 'toast';
|
||
}, 3000);
|
||
}
|
||
|
||
// Confirmation modal
|
||
let confirmResolve = null;
|
||
|
||
function showConfirm(message, title = null) {
|
||
return new Promise((resolve) => {
|
||
confirmResolve = resolve;
|
||
|
||
const modal = document.getElementById('confirm-modal');
|
||
const titleEl = document.getElementById('confirm-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;
|
||
yesBtn.textContent = t('confirm.yes');
|
||
noBtn.textContent = t('confirm.no');
|
||
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
});
|
||
}
|
||
|
||
function closeConfirmModal(result) {
|
||
const modal = document.getElementById('confirm-modal');
|
||
modal.style.display = 'none';
|
||
unlockBody();
|
||
|
||
if (confirmResolve) {
|
||
confirmResolve(result);
|
||
confirmResolve = null;
|
||
}
|
||
}
|
||
|
||
// Calibration functions
|
||
async function showCalibration(deviceId) {
|
||
try {
|
||
// Fetch device data and displays in parallel
|
||
const [response, displaysResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||
]);
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
showToast('Failed to load calibration', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await response.json();
|
||
const calibration = device.calibration;
|
||
|
||
// Set aspect ratio from device's display
|
||
const preview = document.querySelector('.calibration-preview');
|
||
if (displaysResponse.ok) {
|
||
const displaysData = await displaysResponse.json();
|
||
const displayIndex = device.settings?.display_index ?? 0;
|
||
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
|
||
if (display && display.width && display.height) {
|
||
preview.style.aspectRatio = `${display.width} / ${display.height}`;
|
||
} else {
|
||
preview.style.aspectRatio = '';
|
||
}
|
||
} else {
|
||
preview.style.aspectRatio = '';
|
||
}
|
||
|
||
// Store device ID and LED count
|
||
document.getElementById('calibration-device-id').value = device.id;
|
||
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
|
||
|
||
// Set layout
|
||
document.getElementById('cal-start-position').value = calibration.start_position;
|
||
document.getElementById('cal-layout').value = calibration.layout;
|
||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||
|
||
// Set LED counts per edge
|
||
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;
|
||
|
||
// Initialize edge spans
|
||
window.edgeSpans = {
|
||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
|
||
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
|
||
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
|
||
};
|
||
|
||
// Snapshot initial values for dirty checking
|
||
calibrationInitialValues = {
|
||
start_position: calibration.start_position,
|
||
layout: calibration.layout,
|
||
offset: String(calibration.offset || 0),
|
||
top: String(calibration.leds_top || 0),
|
||
right: String(calibration.leds_right || 0),
|
||
bottom: String(calibration.leds_bottom || 0),
|
||
left: String(calibration.leds_left || 0),
|
||
spans: JSON.stringify(window.edgeSpans),
|
||
};
|
||
|
||
// Initialize test mode state for this device
|
||
calibrationTestState[device.id] = new Set();
|
||
|
||
// Update preview
|
||
updateCalibrationPreview();
|
||
|
||
// Show modal
|
||
const modal = document.getElementById('calibration-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
|
||
// Initialize span drag and render canvas after layout settles
|
||
initSpanDrag();
|
||
requestAnimationFrame(() => renderCalibrationCanvas());
|
||
|
||
// Re-render on container resize (e.g. window resize changes aspect-ratio container)
|
||
if (!window._calibrationResizeObserver) {
|
||
window._calibrationResizeObserver = new ResizeObserver(() => {
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
});
|
||
}
|
||
window._calibrationResizeObserver.observe(preview);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load calibration:', error);
|
||
showToast('Failed to load calibration', 'error');
|
||
}
|
||
}
|
||
|
||
function isCalibrationDirty() {
|
||
return (
|
||
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
|
||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
|
||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
|
||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
|
||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
|
||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
|
||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans
|
||
);
|
||
}
|
||
|
||
function forceCloseCalibrationModal() {
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
if (deviceId) {
|
||
clearTestMode(deviceId);
|
||
}
|
||
if (window._calibrationResizeObserver) {
|
||
window._calibrationResizeObserver.disconnect();
|
||
}
|
||
const modal = document.getElementById('calibration-modal');
|
||
const error = document.getElementById('calibration-error');
|
||
modal.style.display = 'none';
|
||
error.style.display = 'none';
|
||
unlockBody();
|
||
calibrationInitialValues = {};
|
||
}
|
||
|
||
async function closeCalibrationModal() {
|
||
if (isCalibrationDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseCalibrationModal();
|
||
}
|
||
|
||
function updateCalibrationPreview() {
|
||
// Calculate total from edge inputs
|
||
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);
|
||
// Warning if total doesn't match device LED count
|
||
const totalEl = document.querySelector('.preview-screen-total');
|
||
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
||
const mismatch = total !== deviceCount;
|
||
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
|
||
if (totalEl) {
|
||
totalEl.classList.toggle('mismatch', mismatch);
|
||
}
|
||
|
||
// Update corner dot highlights for start position
|
||
const startPos = document.getElementById('cal-start-position').value;
|
||
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
|
||
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
|
||
if (cornerEl) {
|
||
if (corner === startPos) {
|
||
cornerEl.classList.add('active');
|
||
} else {
|
||
cornerEl.classList.remove('active');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update direction toggle display
|
||
const direction = document.getElementById('cal-layout').value;
|
||
const dirIcon = document.getElementById('direction-icon');
|
||
const dirLabel = document.getElementById('direction-label');
|
||
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
|
||
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
||
|
||
// Update edge highlight states
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const activeEdges = calibrationTestState[deviceId] || new Set();
|
||
|
||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||
if (!toggleEl) return;
|
||
|
||
if (activeEdges.has(edge)) {
|
||
const [r, g, b] = EDGE_TEST_COLORS[edge];
|
||
toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`;
|
||
toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`;
|
||
} else {
|
||
toggleEl.style.background = '';
|
||
toggleEl.style.boxShadow = '';
|
||
}
|
||
});
|
||
|
||
// Position span bars and render canvas overlay
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function renderCalibrationCanvas() {
|
||
const canvas = document.getElementById('calibration-preview-canvas');
|
||
if (!canvas) return;
|
||
|
||
const container = canvas.parentElement;
|
||
const containerRect = container.getBoundingClientRect();
|
||
if (containerRect.width === 0 || containerRect.height === 0) return;
|
||
|
||
// Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px)
|
||
const padX = 40;
|
||
const padY = 40;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const canvasW = containerRect.width + padX * 2;
|
||
const canvasH = containerRect.height + padY * 2;
|
||
canvas.width = canvasW * dpr;
|
||
canvas.height = canvasH * dpr;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
|
||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||
|
||
// Container origin within canvas coordinate system
|
||
const ox = padX;
|
||
const oy = padY;
|
||
const cW = containerRect.width; // container inner width
|
||
const cH = containerRect.height; // container inner height
|
||
|
||
// Read current form values
|
||
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 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),
|
||
};
|
||
|
||
const segments = buildSegments(calibration);
|
||
if (segments.length === 0) return;
|
||
|
||
const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left;
|
||
|
||
// Theme-aware colors
|
||
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
|
||
const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)';
|
||
const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)';
|
||
|
||
// Edge bar geometry (matches CSS: corner zones 56px × 36px fixed)
|
||
const cw = 56;
|
||
const ch = 36;
|
||
|
||
// Span-aware edge geometry: ticks/arrows render only within the span region
|
||
const spans = window.edgeSpans || {};
|
||
const edgeLenH = cW - 2 * cw;
|
||
const edgeLenV = cH - 2 * ch;
|
||
|
||
const edgeGeometry = {
|
||
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 },
|
||
right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false },
|
||
};
|
||
|
||
// Axis positions for labels (outside the 16px toggle zones)
|
||
const toggleSize = 16;
|
||
const axisPos = {
|
||
top: oy - toggleSize - 3,
|
||
bottom: oy + cH + toggleSize + 3,
|
||
left: ox - toggleSize - 3,
|
||
right: ox + cW + toggleSize + 3,
|
||
};
|
||
|
||
// Arrow positions (inside the screen area, near each edge bar)
|
||
const arrowInset = 12;
|
||
const arrowPos = {
|
||
top: oy + ch + arrowInset,
|
||
bottom: oy + cH - ch - arrowInset,
|
||
left: ox + cw + arrowInset,
|
||
right: ox + cW - cw - arrowInset,
|
||
};
|
||
|
||
// Draw ticks and direction arrows for each segment
|
||
segments.forEach(seg => {
|
||
const geo = edgeGeometry[seg.edge];
|
||
if (!geo) return;
|
||
|
||
const count = seg.led_count;
|
||
if (count === 0) return;
|
||
|
||
// Mandatory ticks: first and last LED index per edge, plus LED 0 if offset > 0
|
||
const labelsToShow = new Set();
|
||
labelsToShow.add(0);
|
||
if (count > 1) labelsToShow.add(count - 1);
|
||
|
||
if (offset > 0 && totalLeds > 0) {
|
||
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
|
||
if (zeroPos < count) labelsToShow.add(zeroPos);
|
||
}
|
||
|
||
// Add intermediate ticks at "nice" intervals (max 5 labels per edge)
|
||
if (count > 2) {
|
||
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
|
||
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
|
||
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
|
||
|
||
const maxIntermediate = Math.max(0, 5 - labelsToShow.size);
|
||
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
|
||
let step = niceSteps[niceSteps.length - 1];
|
||
for (const s of niceSteps) {
|
||
if (Math.floor(count / s) <= maxIntermediate) {
|
||
step = s;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Pixel position helper (0..edgeLen along the edge)
|
||
const tickPx = i => {
|
||
const f = i / (count - 1);
|
||
return (seg.reverse ? (1 - f) : f) * edgeLen;
|
||
};
|
||
|
||
// Collect pixel positions of mandatory ticks
|
||
const placed = [];
|
||
labelsToShow.forEach(i => placed.push(tickPx(i)));
|
||
|
||
// Add ticks at LED indices divisible by step
|
||
for (let i = 1; i < count - 1; i++) {
|
||
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||
if (idx % step === 0) {
|
||
const px = tickPx(i);
|
||
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
|
||
labelsToShow.add(i);
|
||
placed.push(px);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tick styling — min/max ticks extend to container border, others short
|
||
const tickLenLong = toggleSize + 3;
|
||
const tickLenShort = 4;
|
||
ctx.strokeStyle = tickStroke;
|
||
ctx.lineWidth = 1;
|
||
ctx.fillStyle = tickFill;
|
||
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
|
||
labelsToShow.forEach(i => {
|
||
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||
const tickLen = (i === 0 || i === count - 1) ? tickLenLong : tickLenShort;
|
||
|
||
if (geo.horizontal) {
|
||
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||
const axisY = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'top' ? 1 : -1; // tick toward container
|
||
|
||
// Tick line
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, axisY);
|
||
ctx.lineTo(tx, axisY + tickDir * tickLen);
|
||
ctx.stroke();
|
||
|
||
// Label outside
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
|
||
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
|
||
} else {
|
||
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||
const axisX = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'left' ? 1 : -1; // tick toward container
|
||
|
||
// Tick line
|
||
ctx.beginPath();
|
||
ctx.moveTo(axisX, ty);
|
||
ctx.lineTo(axisX + tickDir * tickLen, ty);
|
||
ctx.stroke();
|
||
|
||
// Label outside
|
||
ctx.textBaseline = 'middle';
|
||
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
|
||
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
|
||
}
|
||
});
|
||
|
||
// Draw direction chevron at full-edge midpoint (not affected by span)
|
||
const s = 7;
|
||
let mx, my, angle;
|
||
if (geo.horizontal) {
|
||
mx = ox + cw + edgeLenH / 2;
|
||
my = arrowPos[seg.edge];
|
||
angle = seg.reverse ? Math.PI : 0;
|
||
} else {
|
||
mx = arrowPos[seg.edge];
|
||
my = oy + ch + edgeLenV / 2;
|
||
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.translate(mx, my);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = 'rgba(76, 175, 80, 0.85)';
|
||
ctx.strokeStyle = chevronStroke;
|
||
ctx.lineWidth = 1;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.beginPath();
|
||
ctx.moveTo(-s * 0.5, -s * 0.6);
|
||
ctx.lineTo(s * 0.5, 0);
|
||
ctx.lineTo(-s * 0.5, s * 0.6);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
});
|
||
|
||
}
|
||
|
||
function updateSpanBars() {
|
||
const spans = window.edgeSpans || {};
|
||
const container = document.querySelector('.calibration-preview');
|
||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
|
||
if (!bar) return;
|
||
const span = spans[edge] || { start: 0, end: 1 };
|
||
const edgeEl = bar.parentElement;
|
||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||
|
||
if (isHorizontal) {
|
||
const totalWidth = edgeEl.clientWidth;
|
||
bar.style.left = (span.start * totalWidth) + 'px';
|
||
bar.style.width = ((span.end - span.start) * totalWidth) + 'px';
|
||
} else {
|
||
const totalHeight = edgeEl.clientHeight;
|
||
bar.style.top = (span.start * totalHeight) + 'px';
|
||
bar.style.height = ((span.end - span.start) * totalHeight) + 'px';
|
||
}
|
||
|
||
// Also reposition toggle zone to match span region
|
||
if (!container) return;
|
||
const toggle = container.querySelector(`.toggle-${edge}`);
|
||
if (!toggle) return;
|
||
if (isHorizontal) {
|
||
const cornerW = 56;
|
||
const edgeW = container.clientWidth - 2 * cornerW;
|
||
toggle.style.left = (cornerW + span.start * edgeW) + 'px';
|
||
toggle.style.right = 'auto';
|
||
toggle.style.width = ((span.end - span.start) * edgeW) + 'px';
|
||
} else {
|
||
const cornerH = 36;
|
||
const edgeH = container.clientHeight - 2 * cornerH;
|
||
toggle.style.top = (cornerH + span.start * edgeH) + 'px';
|
||
toggle.style.bottom = 'auto';
|
||
toggle.style.height = ((span.end - span.start) * edgeH) + 'px';
|
||
}
|
||
});
|
||
}
|
||
|
||
function initSpanDrag() {
|
||
const MIN_SPAN = 0.05;
|
||
|
||
document.querySelectorAll('.edge-span-bar').forEach(bar => {
|
||
const edge = bar.dataset.edge;
|
||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||
|
||
// Prevent edge click-through when interacting with span bar
|
||
bar.addEventListener('click', e => e.stopPropagation());
|
||
|
||
// Handle resize via handles
|
||
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
|
||
handle.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const handleType = handle.dataset.handle;
|
||
const edgeEl = bar.parentElement;
|
||
const rect = edgeEl.getBoundingClientRect();
|
||
|
||
function onMouseMove(ev) {
|
||
const span = window.edgeSpans[edge];
|
||
let fraction;
|
||
if (isHorizontal) {
|
||
fraction = (ev.clientX - rect.left) / rect.width;
|
||
} else {
|
||
fraction = (ev.clientY - rect.top) / rect.height;
|
||
}
|
||
fraction = Math.max(0, Math.min(1, fraction));
|
||
|
||
if (handleType === 'start') {
|
||
span.start = Math.min(fraction, span.end - MIN_SPAN);
|
||
} else {
|
||
span.end = Math.max(fraction, span.start + MIN_SPAN);
|
||
}
|
||
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
});
|
||
|
||
// Handle body drag (move entire span)
|
||
bar.addEventListener('mousedown', e => {
|
||
if (e.target.classList.contains('edge-span-handle')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const edgeEl = bar.parentElement;
|
||
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;
|
||
}
|
||
const offsetInSpan = startFraction - span.start;
|
||
|
||
function onMouseMove(ev) {
|
||
let fraction;
|
||
if (isHorizontal) {
|
||
fraction = (ev.clientX - rect.left) / rect.width;
|
||
} else {
|
||
fraction = (ev.clientY - rect.top) / rect.height;
|
||
}
|
||
|
||
let newStart = fraction - offsetInSpan;
|
||
newStart = Math.max(0, Math.min(1 - spanWidth, newStart));
|
||
span.start = newStart;
|
||
span.end = newStart + spanWidth;
|
||
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
});
|
||
|
||
// Initial positioning
|
||
updateSpanBars();
|
||
}
|
||
|
||
function setStartPosition(position) {
|
||
document.getElementById('cal-start-position').value = position;
|
||
updateCalibrationPreview();
|
||
}
|
||
|
||
function toggleEdgeInputs() {
|
||
const preview = document.querySelector('.calibration-preview');
|
||
if (preview) preview.classList.toggle('inputs-dimmed');
|
||
}
|
||
|
||
function toggleDirection() {
|
||
const select = document.getElementById('cal-layout');
|
||
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
|
||
updateCalibrationPreview();
|
||
}
|
||
|
||
async function toggleTestEdge(edge) {
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const error = document.getElementById('calibration-error');
|
||
|
||
if (!calibrationTestState[deviceId]) {
|
||
calibrationTestState[deviceId] = new Set();
|
||
}
|
||
|
||
// Toggle edge
|
||
if (calibrationTestState[deviceId].has(edge)) {
|
||
calibrationTestState[deviceId].delete(edge);
|
||
} else {
|
||
calibrationTestState[deviceId].add(edge);
|
||
}
|
||
|
||
// Build edges dict for API
|
||
const edges = {};
|
||
calibrationTestState[deviceId].forEach(e => {
|
||
edges[e] = EDGE_TEST_COLORS[e];
|
||
});
|
||
|
||
// Update visual state immediately
|
||
updateCalibrationPreview();
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ edges })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
error.textContent = `Test failed: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle test edge:', err);
|
||
error.textContent = 'Failed to toggle test edge';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function clearTestMode(deviceId) {
|
||
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
|
||
return;
|
||
}
|
||
|
||
calibrationTestState[deviceId] = new Set();
|
||
|
||
try {
|
||
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ edges: {} })
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to clear test mode:', err);
|
||
}
|
||
}
|
||
|
||
async function saveCalibration() {
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
|
||
const error = document.getElementById('calibration-error');
|
||
|
||
// Clear test mode before saving
|
||
await clearTestMode(deviceId);
|
||
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 total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||
|
||
// Validation
|
||
if (total !== deviceLedCount) {
|
||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Build calibration config
|
||
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 spans = window.edgeSpans || {};
|
||
const calibration = {
|
||
layout: layout,
|
||
start_position: startPosition,
|
||
offset: offset,
|
||
leds_top: topLeds,
|
||
leds_right: rightLeds,
|
||
leds_bottom: bottomLeds,
|
||
leds_left: leftLeds,
|
||
span_top_start: spans.top?.start ?? 0,
|
||
span_top_end: spans.top?.end ?? 1,
|
||
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,
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify(calibration)
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
showToast('Calibration saved', 'success');
|
||
forceCloseCalibrationModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await response.json();
|
||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save calibration:', err);
|
||
error.textContent = 'Failed to save calibration';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function getEdgeOrder(startPosition, layout) {
|
||
const orders = {
|
||
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
|
||
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
|
||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
|
||
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
|
||
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
|
||
};
|
||
|
||
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
|
||
}
|
||
|
||
function shouldReverse(edge, startPosition, layout) {
|
||
// Determine if this edge should be reversed based on LED strip direction
|
||
const reverseRules = {
|
||
'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 },
|
||
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
|
||
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
||
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
|
||
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
|
||
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
|
||
};
|
||
|
||
const rules = reverseRules[`${startPosition}_${layout}`];
|
||
return rules ? rules[edge] : false;
|
||
}
|
||
|
||
function buildSegments(calibration) {
|
||
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
|
||
const edgeCounts = {
|
||
top: calibration.leds_top || 0,
|
||
right: calibration.leds_right || 0,
|
||
bottom: calibration.leds_bottom || 0,
|
||
left: calibration.leds_left || 0
|
||
};
|
||
|
||
const segments = [];
|
||
let ledStart = calibration.offset || 0;
|
||
|
||
edgeOrder.forEach(edge => {
|
||
const count = edgeCounts[edge];
|
||
if (count > 0) {
|
||
segments.push({
|
||
edge: edge,
|
||
led_start: ledStart,
|
||
led_count: count,
|
||
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
|
||
});
|
||
ledStart += count;
|
||
}
|
||
});
|
||
|
||
return segments;
|
||
}
|
||
|
||
// Close modals on backdrop click (only if mousedown also started on backdrop)
|
||
let backdropMouseDownTarget = null;
|
||
document.addEventListener('mousedown', (e) => {
|
||
backdropMouseDownTarget = e.target;
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.classList.contains('modal')) return;
|
||
if (backdropMouseDownTarget !== e.target) return;
|
||
|
||
const modalId = e.target.id;
|
||
|
||
// Confirm modal: backdrop click acts as Cancel
|
||
if (modalId === 'confirm-modal') {
|
||
closeConfirmModal(false);
|
||
return;
|
||
}
|
||
|
||
// Login modal: close only if cancel button is visible (not required login)
|
||
if (modalId === 'api-key-modal') {
|
||
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||
if (cancelBtn && cancelBtn.style.display !== 'none') {
|
||
closeApiKeyModal();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Settings modal: dirty check
|
||
if (modalId === 'device-settings-modal') {
|
||
closeDeviceSettingsModal();
|
||
return;
|
||
}
|
||
|
||
// Calibration modal: dirty check
|
||
if (modalId === 'calibration-modal') {
|
||
closeCalibrationModal();
|
||
return;
|
||
}
|
||
|
||
// Add device modal: close on backdrop
|
||
if (modalId === 'add-device-modal') {
|
||
closeAddDeviceModal();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Cleanup on page unload
|
||
window.addEventListener('beforeunload', () => {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
});
|