`;
}
/**
* Attach event listeners to the rendered content
*/
function attachEventListeners() {
// Range slider value updates
const thresholdInput = document.getElementById('setting-threshold');
const thresholdValue = document.getElementById('setting-threshold-value');
if (thresholdInput && thresholdValue) {
thresholdInput.addEventListener('input', function() {
thresholdValue.textContent = (parseFloat(this.value) * 1000).toFixed(0);
});
}
const gridCellInput = document.getElementById('setting-grid-cell');
const gridCellValue = document.getElementById('setting-grid-cell-value');
if (gridCellInput && gridCellValue) {
gridCellInput.addEventListener('input', function() {
gridCellValue.textContent = parseFloat(this.value).toFixed(2) + ' m';
});
}
const fresnelDecayInput = document.getElementById('setting-fresnel-decay');
const fresnelDecayValue = document.getElementById('setting-fresnel-decay-value');
if (fresnelDecayInput && fresnelDecayValue) {
fresnelDecayInput.addEventListener('input', function() {
fresnelDecayValue.textContent = parseFloat(this.value).toFixed(1);
});
}
const tauInput = document.getElementById('setting-tau');
const tauValue = document.getElementById('setting-tau-value');
if (tauInput && tauValue) {
tauInput.addEventListener('input', function() {
tauValue.textContent = parseInt(this.value) + ' s';
});
}
// Channel type selector - show/hide relevant config fields
const channelTypeSelect = document.getElementById('notification-channel-type');
if (channelTypeSelect) {
channelTypeSelect.addEventListener('change', function() {
updateChannelConfigVisibility(this.value);
});
}
// Quiet hours toggle
const quietHoursEnabled = document.getElementById('quiet-hours-enabled');
const quietHoursFields = document.getElementById('quiet-hours-fields');
if (quietHoursEnabled && quietHoursFields) {
quietHoursEnabled.addEventListener('change', function() {
quietHoursFields.style.display = this.checked ? '' : 'none';
});
}
// Morning digest toggle
const morningDigestEnabled = document.getElementById('morning-digest-enabled');
const morningDigestFields = document.getElementById('morning-digest-fields');
if (morningDigestEnabled && morningDigestFields) {
morningDigestEnabled.addEventListener('change', function() {
morningDigestFields.style.display = this.checked ? '' : 'none';
});
}
// Smart batching toggle
const smartBatchingEnabled = document.getElementById('smart-batching-enabled');
const smartBatchingFields = document.getElementById('smart-batching-fields');
if (smartBatchingEnabled && smartBatchingFields) {
smartBatchingEnabled.addEventListener('change', function() {
smartBatchingFields.style.display = this.checked ? '' : 'none';
});
}
// Save detection settings
const saveDetectionBtn = document.getElementById('save-detection-btn');
if (saveDetectionBtn) {
saveDetectionBtn.addEventListener('click', saveDetectionSettings);
}
// Save notification settings
const saveNotificationBtn = document.getElementById('save-notification-btn');
if (saveNotificationBtn) {
saveNotificationBtn.addEventListener('click', saveNotificationSettings);
}
// Test notification
const testNotificationBtn = document.getElementById('test-notification-btn');
if (testNotificationBtn) {
testNotificationBtn.addEventListener('click', sendTestNotification);
}
// Logout
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogout);
}
// Change PIN
const changePinBtn = document.getElementById('change-pin-btn');
if (changePinBtn) {
changePinBtn.addEventListener('click', openChangePINModal);
}
}
/**
* Update channel config visibility based on selected channel type
*/
function updateChannelConfigVisibility(channelType) {
const ntfyConfig = document.getElementById('ntfy-config');
const pushoverConfig = document.getElementById('pushover-config');
const webhookConfig = document.getElementById('webhook-config');
// Hide all first
if (ntfyConfig) ntfyConfig.style.display = 'none';
if (pushoverConfig) pushoverConfig.style.display = 'none';
if (webhookConfig) webhookConfig.style.display = 'none';
// Show selected
switch (channelType) {
case 'ntfy':
if (ntfyConfig) ntfyConfig.style.display = '';
break;
case 'pushover':
if (pushoverConfig) pushoverConfig.style.display = '';
break;
case 'webhook':
if (webhookConfig) webhookConfig.style.display = '';
break;
}
}
/**
* Save detection settings
*/
function saveDetectionSettings() {
const threshold = parseFloat(document.getElementById('setting-threshold').value);
const fusionRate = parseInt(document.getElementById('setting-fusion-rate').value);
const gridCell = parseFloat(document.getElementById('setting-grid-cell').value);
const fresnelDecay = parseFloat(document.getElementById('setting-fresnel-decay').value);
const nSubcarriers = parseInt(document.getElementById('setting-subcarriers').value);
const tau = parseInt(document.getElementById('setting-tau').value);
const updates = {
delta_rms_threshold: threshold,
fusion_rate_hz: fusionRate,
grid_cell_m: gridCell,
fresnel_decay: fresnelDecay,
n_subcarriers: nSubcarriers,
tau_s: tau
};
saveSettings(updates).then(function() {
// Update local state settings
if (window.SpaxelState) {
Object.assign(window.SpaxelState.settings, updates);
}
// Track setting changes for proactive assistance
if (window.Proactive) {
// Track each qualifying setting that changed
const qualifyingSettings = ['delta_rms_threshold', 'fresnel_decay', 'n_subcarriers', 'tau_s', 'breathing_sensitivity'];
for (const key in updates) {
if (qualifyingSettings.includes(key)) {
window.Proactive.trackSettingChange(key, updates[key]);
}
}
}
});
}
/**
* Save notification settings
*/
function saveNotificationSettings() {
const channelType = document.getElementById('notification-channel-type').value;
const channelConfig = {};
// Get channel-specific config
switch (channelType) {
case 'ntfy':
channelConfig.url = document.getElementById('ntfy-server-url').value || null;
channelConfig.topic = document.getElementById('ntfy-topic').value || null;
channelConfig.token = document.getElementById('ntfy-token').value || null;
break;
case 'pushover':
channelConfig.api_key = document.getElementById('pushover-api-key').value || null;
break;
case 'webhook':
channelConfig.url = document.getElementById('webhook-url').value || null;
break;
}
// Get quiet hours settings
const quietHoursEnabled = document.getElementById('quiet-hours-enabled').checked;
const quietHoursStart = document.getElementById('quiet-hours-start').value;
const quietHoursEnd = document.getElementById('quiet-hours-end').value;
// Get quiet hours days mask
let quietHoursDays = 0;
document.querySelectorAll('.day-checkbox-input:checked').forEach(function(cb) {
quietHoursDays |= (1 << parseInt(cb.dataset.day));
});
// Get morning digest settings
const morningDigestEnabled = document.getElementById('morning-digest-enabled').checked;
const morningDigestTime = document.getElementById('morning-digest-time').value;
// Get smart batching settings
const smartBatchingEnabled = document.getElementById('smart-batching-enabled').checked;
const smartBatchingWindow = parseInt(document.getElementById('smart-batching-window').value);
// Get event type preferences
const eventTypes = {};
document.querySelectorAll('.event-type-checkbox:checked').forEach(function(cb) {
eventTypes[cb.dataset.event] = true;
});
document.querySelectorAll('.event-type-checkbox:not(:checked)').forEach(function(cb) {
eventTypes[cb.dataset.event] = false;
});
// Build notification settings object
const notificationSettings = {
channel_type: channelType,
channel_config: Object.keys(channelConfig).length > 0 ? channelConfig : null,
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursStart,
quiet_hours_end: quietHoursEnd,
quiet_hours_days: quietHoursDays,
morning_digest_enabled: morningDigestEnabled,
morning_digest_time: morningDigestTime,
smart_batching_enabled: smartBatchingEnabled,
smart_batching_window: smartBatchingWindow,
event_types: eventTypes
};
// Send to API
fetch('/api/settings/notifications', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(notificationSettings)
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(err) {
throw new Error(err.error || 'Failed to save notification settings');
});
}
return res.json();
})
.then(function(data) {
// Update local state
settingsState.notificationSettings = data;
SpaxelPanels.showSuccess('Notification settings saved successfully');
renderContent();
})
.catch(function(err) {
console.error('[SettingsPanel] Error saving notification settings:', err);
SpaxelPanels.showError('Failed to save notification settings: ' + err.message);
});
}
/**
* Send a test notification
*/
function sendTestNotification() {
SpaxelPanels.showInfo('Sending test notification...');
// Get current channel type from settings
const channelType = document.getElementById('notification-channel-type').value;
if (channelType === 'none') {
SpaxelPanels.showError('Please configure a notification channel first');
return;
}
fetch('/api/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel_type: channelType,
title: 'Spaxel Test Notification',
body: 'This is a test notification from Spaxel.',
data: { test: true }
})
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(err) {
throw new Error(err.error || 'Failed to send test notification');
});
}
return res.json();
})
.then(function(data) {
if (data.status === 'sent') {
SpaxelPanels.showSuccess('Test notification sent successfully!');
} else if (data.status === 'simulated') {
SpaxelPanels.showInfo('Test notification simulated (notification sender not attached)');
} else {
SpaxelPanels.showSuccess(data.message || 'Test notification processed');
}
})
.catch(function(err) {
console.error('[SettingsPanel] Error sending test notification:', err);
SpaxelPanels.showError('Failed to send test notification: ' + err.message);
});
}
/**
* Handle logout
*/
function handleLogout() {
// Auth is handled by Traefik/Google OAuth — no in-app logout needed
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
// ============================================
// Change PIN Modal
// ============================================
const changePINState = {
oldPin: '',
newPin: '',
confirmPin: '',
error: '',
isSubmitting: false
};
function openChangePINModal() {
changePINState.oldPin = '';
changePINState.newPin = '';
changePINState.confirmPin = '';
changePINState.error = '';
changePINState.isSubmitting = false;
const modal = document.createElement('div');
modal.id = 'change-pin-modal';
modal.className = 'panel-modal-overlay';
modal.innerHTML = renderChangePINModal();
document.body.appendChild(modal);
attachChangePINEvents(modal);
// Focus first input
setTimeout(function() {
const firstInput = modal.querySelector('#change-pin-old');
if (firstInput) firstInput.focus();
}, 10);
}
function closeChangePINModal() {
const modal = document.getElementById('change-pin-modal');
if (modal) {
modal.remove();
}
}
function renderChangePINModal() {
return `
Change PIN
${changePINState.error ? `
${escapeHtml(changePINState.error)}
` : ''}
`;
}
function attachChangePINEvents(modal) {
// Close button
const closeBtn = modal.querySelector('#change-pin-close');
if (closeBtn) {
closeBtn.addEventListener('click', closeChangePINModal);
}
// Cancel button
const cancelBtn = modal.querySelector('#change-pin-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeChangePINModal);
}
// Submit button
const submitBtn = modal.querySelector('#change-pin-submit');
if (submitBtn) {
submitBtn.addEventListener('click', submitChangePIN);
}
// Close on overlay click
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeChangePINModal();
}
});
// Handle Enter key
const inputs = modal.querySelectorAll('.panel-input');
inputs.forEach(function(input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
submitChangePIN();
}
});
// Only allow digits
input.addEventListener('input', function(e) {
const value = e.target.value;
if (!/^\d*$/.test(value)) {
e.target.value = value.replace(/\D/g, '');
}
});
});
}
function submitChangePIN() {
const oldPinInput = document.getElementById('change-pin-old');
const newPinInput = document.getElementById('change-pin-new');
const confirmPinInput = document.getElementById('change-pin-confirm');
if (!oldPinInput || !newPinInput || !confirmPinInput) {
return;
}
const oldPin = oldPinInput.value.trim();
const newPin = newPinInput.value.trim();
const confirmPin = confirmPinInput.value.trim();
// Validation
if (!oldPin || oldPin.length < 4) {
changePINState.error = 'Please enter your current PIN (4-8 digits)';
updateChangePINModal();
return;
}
if (!newPin || newPin.length < 4 || newPin.length > 8) {
changePINState.error = 'New PIN must be 4-8 digits';
updateChangePINModal();
return;
}
if (newPin !== confirmPin) {
changePINState.error = 'New PINs do not match';
updateChangePINModal();
return;
}
if (oldPin === newPin) {
changePINState.error = 'New PIN must be different from current PIN';
updateChangePINModal();
return;
}
changePINState.oldPin = oldPin;
changePINState.newPin = newPin;
changePINState.confirmPin = confirmPin;
changePINState.error = '';
changePINState.isSubmitting = true;
updateChangePINModal();
// Send change PIN request
fetch('/api/auth/change-pin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_pin: oldPin,
new_pin: newPin
})
})
.then(function(res) {
if (res.status === 403) {
throw new Error('Incorrect current PIN');
}
if (!res.ok) {
return res.text().then(function(text) {
throw new Error(text || 'Failed to change PIN');
});
}
return res.json();
})
.then(function(data) {
closeChangePINModal();
SpaxelPanels.showSuccess('PIN changed successfully');
})
.catch(function(err) {
changePINState.error = err.message || 'Failed to change PIN';
changePINState.isSubmitting = false;
updateChangePINModal();
});
}
function updateChangePINModal() {
const modal = document.getElementById('change-pin-modal');
if (!modal) return;
const modalContent = modal.querySelector('.panel-modal');
if (modalContent) {
modalContent.innerHTML = renderChangePINModal().match(/
([\s\S]*)<\/div>/)[1];
attachChangePINEvents(modal);
}
}
// ============================================
// Panel Registration
// ============================================
/**
* Open the settings panel
*/
function openSettingsPanel() {
// Fetch settings first, then open panel
fetchSettings().then(function() {
SpaxelPanels.openSidebar({
title: 'Settings',
content: '',
width: '400px',
onOpen: function() {
renderContent();
}
});
}).catch(function() {
// Open panel anyway with error state
SpaxelPanels.openSidebar({
title: 'Settings',
content: '
' + renderLoading() + '
',
width: '400px'
});
});
}
// Register the settings panel
if (window.SpaxelPanels) {
SpaxelPanels.register('settings', openSettingsPanel);
}
// Also register as a global function for direct access
window.openSettingsPanel = openSettingsPanel;
// ============================================
// Router Integration
// ============================================
// Auto-open settings panel when navigating to #settings
if (window.SpaxelRouter) {
SpaxelRouter.onModeChange(function(newMode) {
if (newMode === 'settings') {
openSettingsPanel();
}
});
}
console.log('[SettingsPanel] Settings panel module loaded');
})();