312 lines
12 KiB
JavaScript
312 lines
12 KiB
JavaScript
function toast(message, error = false) {
|
||
const host = $('#toastStack'); if (!host) return;
|
||
const item = document.createElement('div');
|
||
item.className = `toast-item ${error ? 'error' : 'success'}`;
|
||
item.innerHTML = `<span class="toast-icon">${error ? '!' : '✓'}</span><div class="toast-copy"><strong>${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}</strong><span>${esc(message)}</span></div><button class="toast-close" type="button" aria-label="${esc(tr('actions.close'))}">×</button><i class="toast-progress"></i>`;
|
||
host.appendChild(item);
|
||
requestAnimationFrame(() => item.classList.add('show'));
|
||
const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); };
|
||
item.querySelector('.toast-close').addEventListener('click', remove);
|
||
item._timer = setTimeout(remove, error ? 5200 : 3600);
|
||
}
|
||
|
||
const cleanFormSnapshots = new WeakMap();
|
||
|
||
function formSnapshot(form) {
|
||
if (!form) return '';
|
||
const rows = [];
|
||
$$('input, select, textarea', form).forEach((field, index) => {
|
||
if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return;
|
||
const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`;
|
||
const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? '');
|
||
rows.push([key, value, String(field.disabled)]);
|
||
});
|
||
return JSON.stringify(rows);
|
||
}
|
||
|
||
function markFormClean(form) {
|
||
if (!form) return;
|
||
cleanFormSnapshots.set(form, formSnapshot(form));
|
||
form.classList.remove('has-unsaved-changes');
|
||
}
|
||
|
||
function updateDirtyIndicator(form) {
|
||
if (!form || !cleanFormSnapshots.has(form)) return;
|
||
form.classList.toggle('has-unsaved-changes', isFormDirty(form));
|
||
}
|
||
|
||
function isFormDirty(form) {
|
||
if (!form || !cleanFormSnapshots.has(form)) return false;
|
||
return cleanFormSnapshots.get(form) !== formSnapshot(form);
|
||
}
|
||
|
||
function clearFormErrors(form) {
|
||
if (!form) return;
|
||
$$('.field-error, .form-error-summary', form).forEach(node => node.remove());
|
||
$$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid'));
|
||
$$('.has-error', form).forEach(node => node.classList.remove('has-error'));
|
||
}
|
||
|
||
function showFieldError(field, message) {
|
||
if (!field || !message) return;
|
||
field.setAttribute('aria-invalid', 'true');
|
||
const label = field.closest('label');
|
||
if (label) label.classList.add('has-error');
|
||
const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null;
|
||
if (existing) { existing.textContent = message; return; }
|
||
const note = document.createElement('small');
|
||
note.className = 'field-error';
|
||
note.textContent = message;
|
||
field.insertAdjacentElement('afterend', note);
|
||
}
|
||
|
||
function showFormError(form, message) {
|
||
if (!form || !message) return;
|
||
const summary = document.createElement('div');
|
||
summary.className = 'form-error-summary';
|
||
summary.setAttribute('role', 'alert');
|
||
summary.textContent = message;
|
||
const anchor = $('.settings-save-bar', form) || $('.form-actions', form);
|
||
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
|
||
else form.appendChild(summary);
|
||
}
|
||
|
||
function validationMessageFor(field) {
|
||
if (field.validity?.valueMissing) return tr('validation.required');
|
||
if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid');
|
||
if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) {
|
||
return tr('validation.range', { min: field.min || '—', max: field.max || '—' });
|
||
}
|
||
if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid');
|
||
return field.validity?.valid === false ? tr('validation.invalid') : '';
|
||
}
|
||
|
||
function httpUrlValid(value) {
|
||
if (!String(value || '').trim()) return true;
|
||
try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; }
|
||
}
|
||
|
||
function entityIdValid(value) {
|
||
return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim());
|
||
}
|
||
|
||
function validateDecimalField(form, name, min, max) {
|
||
const field = form?.elements?.[name];
|
||
if (!field || field.disabled) return true;
|
||
const value = parseDecimal(field.value);
|
||
if (!Number.isFinite(value) || value < min || value > max) {
|
||
showFieldError(field, tr('validation.range', { min, max }));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function validateForm(form) {
|
||
if (!form) return true;
|
||
clearFormErrors(form);
|
||
let valid = true;
|
||
$$('input, select, textarea', form).forEach(field => {
|
||
if (field.disabled || field.type === 'hidden' || field.type === 'file') return;
|
||
const message = validationMessageFor(field);
|
||
if (message) { showFieldError(field, message); valid = false; }
|
||
});
|
||
|
||
if (form.id === 'homeAssistantForm') {
|
||
const url = form.elements.ha_url;
|
||
if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
|
||
['ha_entity_id', 'ha_outdoor_entity_id'].forEach(name => {
|
||
const field = form.elements[name];
|
||
if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; }
|
||
});
|
||
}
|
||
|
||
if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) {
|
||
const url = form.elements.influx_url;
|
||
if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; }
|
||
else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
|
||
}
|
||
|
||
if (form.id === 'temporaryThermostatForm') {
|
||
if (!validateDecimalField(form, 'target_temperature', 8, 30)) valid = false;
|
||
}
|
||
|
||
if (form.id === 'zoneForm') {
|
||
const source = form.elements.sensor_source?.value;
|
||
const entity = form.elements.ha_entity_id;
|
||
if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) {
|
||
showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required'));
|
||
valid = false;
|
||
}
|
||
const zoneDecimalFields = [
|
||
['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30],
|
||
['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30],
|
||
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8],
|
||
...(form.elements.separate_hysteresis?.checked
|
||
? [['cool_hysteresis', 0.1, 5], ['heat_hysteresis', 0.1, 5]]
|
||
: [['hysteresis', 0.1, 5]])
|
||
];
|
||
zoneDecimalFields.forEach(([name, min, max]) => { if (!validateDecimalField(form, name, min, max)) valid = false; });
|
||
}
|
||
|
||
if (!valid) {
|
||
showFormError(form, tr('validation.fixFields'));
|
||
const first = $('[aria-invalid="true"]', form);
|
||
const pane = first?.closest?.('[data-settings-pane]');
|
||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||
first?.focus({ preventScroll: true });
|
||
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
return valid;
|
||
}
|
||
|
||
function apiErrorField(form, message) {
|
||
const text = String(message || '').toLowerCase();
|
||
const candidates = [];
|
||
if (text.includes('influx')) candidates.push('influx_url');
|
||
if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url');
|
||
if (text.includes('entity')) candidates.push('ha_entity_id', 'ha_outdoor_entity_id');
|
||
if (text.includes('device')) candidates.push('device_id', 'action_device_id', 'trigger_device_id');
|
||
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature', 'cool_comfort_setpoint', 'action_target_temperature');
|
||
if (text.includes('name')) candidates.push('name', 'controller_id');
|
||
if (text.includes('token')) candidates.push('ha_token', 'influx_token', 'pushover_app_token');
|
||
if (text.includes('url')) candidates.push('ha_url', 'influx_url', 'slack_webhook_url', 'discord_webhook_url');
|
||
return candidates.map(name => form?.elements?.[name]).find(Boolean) || null;
|
||
}
|
||
|
||
function presentFormError(form, error) {
|
||
clearFormErrors(form);
|
||
const message = error?.message || tr('validation.invalid');
|
||
const field = apiErrorField(form, message);
|
||
if (field) showFieldError(field, message);
|
||
showFormError(form, message);
|
||
if (field) {
|
||
const pane = field.closest?.('[data-settings-pane]');
|
||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||
field.focus({ preventScroll: true });
|
||
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
toast(message, true);
|
||
}
|
||
|
||
function setFormBusy(form, busy, busyKey = 'actions.saving') {
|
||
if (!form) return;
|
||
const buttons = $$('button[type="submit"]', form);
|
||
if (busy) {
|
||
form.dataset.submitting = 'true';
|
||
form.setAttribute('aria-busy', 'true');
|
||
form.classList.add('is-saving');
|
||
buttons.forEach(button => {
|
||
if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML;
|
||
button.disabled = true;
|
||
button.innerHTML = `<span class="button-spinner" aria-hidden="true"></span><span>${esc(tr(busyKey))}</span>`;
|
||
});
|
||
} else {
|
||
delete form.dataset.submitting;
|
||
form.removeAttribute('aria-busy');
|
||
form.classList.remove('is-saving');
|
||
buttons.forEach(button => {
|
||
button.disabled = false;
|
||
if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; }
|
||
});
|
||
}
|
||
}
|
||
|
||
async function runFormTask(form, task, { busyKey = 'actions.saving', validate = true } = {}) {
|
||
if (!form || form.dataset.submitting === 'true') return { ok: false, duplicate: true };
|
||
if (validate && !validateForm(form)) return { ok: false, validation: true };
|
||
clearFormErrors(form);
|
||
setFormBusy(form, true, busyKey);
|
||
try {
|
||
const value = await task();
|
||
markFormClean(form);
|
||
return { ok: true, value };
|
||
} catch (error) {
|
||
presentFormError(form, error);
|
||
return { ok: false, error };
|
||
} finally {
|
||
setFormBusy(form, false);
|
||
}
|
||
}
|
||
|
||
function restoreTrackedForm(form) {
|
||
if (!form) return;
|
||
if (form.id === 'settingsForm') renderSettings();
|
||
else if (form.id === 'nightModeForm') renderNightSettings();
|
||
else if (form.id === 'homeAssistantForm') {
|
||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
|
||
renderHomeAssistantSettings();
|
||
} else markFormClean(form);
|
||
}
|
||
|
||
function confirmDiscardForm(form) {
|
||
if (!isFormDirty(form)) return true;
|
||
if (!confirm(tr('confirm.discardChanges'))) return false;
|
||
restoreTrackedForm(form);
|
||
return true;
|
||
}
|
||
|
||
function activeDirtySettingsForm() {
|
||
const active = $('.view.active');
|
||
if (!active) return null;
|
||
const form = $('form.settings-form', active);
|
||
return form && isFormDirty(form) ? form : null;
|
||
}
|
||
|
||
function canLeaveCurrentView(nextName) {
|
||
if (nextName === app.currentView) return true;
|
||
const form = activeDirtySettingsForm();
|
||
return !form || confirmDiscardForm(form);
|
||
}
|
||
|
||
function requestDialogClose(dialog) {
|
||
if (!dialog) return true;
|
||
const form = $('form', dialog);
|
||
if (form && !confirmDiscardForm(form)) return false;
|
||
dialog.close();
|
||
return true;
|
||
}
|
||
|
||
function setupFormUx() {
|
||
$$('form').forEach(form => {
|
||
form.noValidate = true;
|
||
if (!cleanFormSnapshots.has(form)) markFormClean(form);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('input', event => {
|
||
const field = event.target.closest?.('input, select, textarea');
|
||
const form = field?.form;
|
||
if (!field || !form) return;
|
||
if (field.getAttribute('aria-invalid') === 'true') {
|
||
field.removeAttribute('aria-invalid');
|
||
field.closest('label')?.classList.remove('has-error');
|
||
if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove();
|
||
$('.form-error-summary', form)?.remove();
|
||
}
|
||
updateDirtyIndicator(form);
|
||
});
|
||
document.addEventListener('change', event => updateDirtyIndicator(event.target?.form));
|
||
|
||
window.addEventListener('beforeunload', event => {
|
||
const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form));
|
||
if (!dirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
});
|
||
|
||
document.addEventListener('cancel', event => {
|
||
const dialog = event.target.closest?.('dialog');
|
||
if (!dialog) return;
|
||
const form = $('form', dialog);
|
||
if (!form || !isFormDirty(form)) return;
|
||
event.preventDefault();
|
||
if (confirmDiscardForm(form)) dialog.close();
|
||
}, true);
|
||
|
||
function showTokenDialog() {
|
||
const dialog = $('#tokenDialog');
|
||
if (!dialog.open) dialog.showModal();
|
||
}
|
||
|