librelad 8a9ae28b6f feat(webui): developer mode + Android-style 10-click easter egg
What this delivers (Stage 1+2 of the dev-mode feature):

1. New `**DEV**` marker for config fields. Mirrors the existing
   `**ADVANCED**` pattern: stays in the description string, frontend
   strips it for display, presence flips a 'hide unless dev mode is on'
   behaviour. Implemented in ConfigUtils.cleanDescription /
   isDevField / isDevModeOn and in ConfigShared._filterDevKeys, which
   the two generateFieldsForCategory* helpers now call before rendering.

2. New CFG_DEV_MODE field in configs/general/general_install. Visible
   under Advanced; defaults to false. The canonical place to toggle
   dev mode (the WebUI easter egg writes to it, the auto-detector
   writes to it, and users can flip it directly here too).

3. Marked CFG_INSTALL_MODE and CFG_RELEASE_CHANNEL with `**DEV**`.
   Normal users no longer see either field — they install Release-
   Stable and that's the whole story. Devs see both with the
   user-facing labels you asked for:
     CFG_INSTALL_MODE        Release - Stable | Git clone | Local folder
     CFG_RELEASE_CHANNEL     Release - Stable | Release - Bleeding Edge
   (CFG_INSTALL_MODE label for the release option also renamed to match.)

4. 10-click LibrePortal-logo easter egg in topbar.js:
   - Counter on any .libreportal-logo click; idle-reset after 3 s
   - Toast countdown from click 6 ('4 clicks away from being a developer…')
   - At 10: toggles CFG_DEV_MODE via the standard config_update task
     (same path the Config form uses); shows '🛠️ Developer mode
     unlocked. Reload to see the extra options.'
   - Re-using the same logo when dev mode is on toggles it back off
     ('… away from disabling developer mode') — symmetric, no separate UI

5. Auto-detect: on every WebUI load, if CFG_INSTALL_MODE is git or
   local AND CFG_DEV_MODE is off, auto-flip to on with a one-time
   toast 'Developer mode auto-enabled — you're on a git install.
   Click the LibrePortal logo 10× to disable.' Stops dev-install
   users getting locked out of the very options they need to manage
   their install. Idempotent — runs once per page load; no-op if
   already on or on release.

Disable surfaces: (a) CFG_DEV_MODE in Advanced on the Config form is
the canonical toggle; (b) 10 more logo clicks. A 3rd surface (a System
page banner) is deferred — those two cover the practical cases.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:49:09 +01:00

1559 lines
61 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Config Shared Functions - Common functionality used across all config components
class ConfigShared {
// Toggle switch system - handles different types of toggles with proper layout
static createToggleSwitch(fieldId, key, value, title, description, options = {}) {
const isChecked = value === 'true';
const tooltipHtml = description ? `<span class="tooltip" title="${description}"></span>` : '';
// Determine toggle type and layout
const toggleType = options.type || 'standard';
const layout = options.layout || 'inline';
let toggleHTML = '';
switch (toggleType) {
case 'master':
toggleHTML = this.createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options);
break;
case 'section':
toggleHTML = this.createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options);
break;
case 'standard':
default:
toggleHTML = this.createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options);
break;
}
// Wrap in layout container
return this.wrapToggleInLayout(toggleHTML, layout, options);
}
// Standard toggle switch (most common)
static createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
return `
<div class="checkbox-field">
<label class="checkbox-label">
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleToggleChange(this, '${key}', '${options.category || ''}')">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${title}
${tooltipHtml}
</span>
</label>
</div>
`;
}
// Master toggle (for enabling/disabling entire sections)
static createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
return `
<div class="field-group">
<label for="${fieldId}">
${title}
${tooltipHtml}
</label>
<label class="checkbox-label master-toggle">
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleMasterToggle(this, '${options.sectionId || ''}', '${options.fieldIds || []}')">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${title}
${tooltipHtml}
</span>
</label>
</div>
`;
}
// Section toggle (for showing/hiding sections)
static createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
return `
<div class="hidden-options-toggle">
<label class="checkbox-label">
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleSectionToggle(this, '${options.sectionId || ''}')">
<span class="checkbox-custom"></span>
${title}
</label>
</div>
`;
}
// Wrap toggle in layout container
static wrapToggleInLayout(toggleHTML, layout, options) {
switch (layout) {
case 'grid':
return `<div class="toggle-grid">${toggleHTML}</div>`;
case 'flex':
return `<div class="toggle-flex">${toggleHTML}</div>`;
case 'inline':
default:
return toggleHTML;
}
}
// Auto-detect and create appropriate field type
static createSmartField(fieldId, key, value, title, description, options = {}) {
//console.log(`createSmartField: key=${key}, value=${value}, config=${!!options.config}`);
// Check if value is boolean (true/false strings)
const isBoolean = value === 'true' || value === 'false';
if (isBoolean) {
// Auto-create toggle switch for boolean values
return this.createToggleSwitch(fieldId, key, value, title, description, {
type: options.type || 'standard',
layout: options.layout || 'inline',
category: options.category || '',
sectionId: options.sectionId || '',
fieldIds: options.fieldIds || []
});
}
// Fall back to regular field generation for non-boolean values
return this.generateField(fieldId, key, value, title, description, options.selectOptions, options.config);
}
// Handle standard toggle changes
static handleToggleChange(checkbox, key, category) {
//console.log(`Toggle changed: ${key} = ${checkbox.checked} (category: ${category})`);
// Trigger custom event for other components to listen to
const event = new CustomEvent('configToggleChanged', {
detail: { key, value: checkbox.checked, category }
});
document.dispatchEvent(event);
// Auto-save if enabled
if (checkbox.dataset.autoSave === 'true') {
this.saveToggleValue(key, checkbox.checked);
}
}
// Handle master toggle changes (enables/disables multiple fields)
static handleMasterToggle(checkbox, sectionId, fieldIds) {
//console.log(`Master toggle changed: ${sectionId} = ${checkbox.checked}`);
// Enable/disable related fields
if (fieldIds && fieldIds.length > 0) {
fieldIds.forEach(fieldId => {
const field = document.getElementById(`config-${fieldId}`);
if (field) {
field.disabled = !checkbox.checked;
field.closest('.config-field')?.classList.toggle('disabled', !checkbox.checked);
}
});
}
// Show/hide section content
if (sectionId) {
const sectionContent = document.getElementById(`${sectionId}-content`);
if (sectionContent) {
sectionContent.style.display = checkbox.checked ? 'block' : 'none';
}
}
// Trigger custom event
const event = new CustomEvent('configMasterToggleChanged', {
detail: { sectionId, enabled: checkbox.checked, fieldIds }
});
document.dispatchEvent(event);
}
// Handle section toggle changes (shows/hides sections)
static handleSectionToggle(checkbox, sectionId) {
//console.log(`Section toggle changed: ${sectionId} = ${checkbox.checked}`);
const content = document.getElementById(`${sectionId}-content`);
if (content) {
content.style.display = checkbox.checked ? 'block' : 'none';
}
// Trigger custom event
const event = new CustomEvent('configSectionToggleChanged', {
detail: { sectionId, visible: checkbox.checked }
});
document.dispatchEvent(event);
}
// Save toggle value immediately
static async saveToggleValue(key, value) {
try {
// For now, just log the change - local implementation
//console.log('Toggle value changed:', key, value ? 'true' : 'false');
// TODO: Implement local config file update
// This would require backend integration to write to actual config files
} catch (error) {
console.error('Error saving toggle value:', error);
}
}
// Create custom range field with start-end inputs
static createRangeField(fieldId, key, value, title, description) {
// Parse range value (format: "start-end")
let startRange = '';
let endRange = '';
if (value && value.includes('-')) {
const parts = value.split('-');
startRange = parts[0] || '';
endRange = parts[1] || '';
}
return `
<div class="range-field" style="display: flex; gap: 8px; align-items: center; width: 100%;">
<input type="number"
id="${fieldId}-start"
class="form-control"
placeholder="Start"
value="${startRange}"
min="1"
max="65535"
onchange="ConfigShared.updateRangeValue('${key}')"
style="flex: 1; min-width: 0;">
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 4px;">-</span>
<input type="number"
id="${fieldId}-end"
class="form-control"
placeholder="End"
value="${endRange}"
min="1"
max="65535"
onchange="ConfigShared.updateRangeValue('${key}')"
style="flex: 1; min-width: 0;">
<input type="hidden" id="${fieldId}" name="${key}" value="${value}">
</div>
<style>
.range-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.range-field input[type="number"] {
text-align: center;
}
.range-field span {
color: var(--text-secondary, #6c757d);
font-weight: bold;
font-size: 16px;
user-select: none;
}
</style>
`;
}
// Update range value when inputs change
static updateRangeValue(key) {
const startInput = document.getElementById(`config-${key}-start`);
const endInput = document.getElementById(`config-${key}-end`);
const hiddenInput = document.getElementById(`config-${key}`);
if (startInput && endInput && hiddenInput) {
const startValue = startInput.value || '';
const endValue = endInput.value || '';
if (startValue && endValue) {
hiddenInput.value = `${startValue}-${endValue}`;
} else if (startValue) {
hiddenInput.value = `${startValue}-`;
} else if (endValue) {
hiddenInput.value = `-${endValue}`;
} else {
hiddenInput.value = '';
}
}
}
// Create custom crontab field with hour/minutes/AM-PM
static createCrontabField(fieldId, key, value, title, description) {
// Parse crontab value (format: "minute hour * * *")
const parts = value.split(' ');
let hour = '5';
let minute = '0';
let period = 'AM';
if (parts.length >= 2) {
minute = parts[0] || '0';
const cronHour = parseInt(parts[1]) || 0;
// Convert 24-hour to 12-hour format
if (cronHour === 0) {
hour = '12';
period = 'AM';
} else if (cronHour < 12) {
hour = cronHour.toString();
period = 'AM';
} else if (cronHour === 12) {
hour = '12';
period = 'PM';
} else {
hour = (cronHour - 12).toString();
period = 'PM';
}
}
return `
<div class="crontab-field" style="display: flex; gap: 4px; align-items: center; width: 100%;">
<select id="${fieldId}-hour" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
${Array.from({length: 12}, (_, i) => i + 1).map(h =>
`<option value="${h}" ${h === parseInt(hour) ? 'selected' : ''}>${h}</option>`
).join('')}
</select>
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 2px;">:</span>
<select id="${fieldId}-minute" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
${Array.from({length: 60}, (_, i) => i.toString().padStart(2, '0')).map(m =>
`<option value="${m}" ${m === minute ? 'selected' : ''}>${m}</option>`
).join('')}
</select>
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 2px;">/</span>
<select id="${fieldId}-period" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
<option value="AM" ${period === 'AM' ? 'selected' : ''}>AM</option>
<option value="PM" ${period === 'PM' ? 'selected' : ''}>PM</option>
</select>
<input type="hidden" id="${fieldId}" name="${key}" value="${value}">
</div>
<style>
.crontab-field {
margin-bottom: 0 !important;
}
.crontab-field select {
font-size: 14px;
height: 38px;
}
</style>
`;
}
// Update crontab value when fields change
static updateCrontabValue(key) {
const fieldId = `config-${key}`;
const hourSelect = document.getElementById(`${fieldId}-hour`);
const minuteSelect = document.getElementById(`${fieldId}-minute`);
const periodSelect = document.getElementById(`${fieldId}-period`);
const hiddenInput = document.getElementById(fieldId);
if (!hourSelect || !minuteSelect || !periodSelect || !hiddenInput) {
console.warn(`Crontab field elements not found for: ${key}`);
return;
}
const hour = parseInt(hourSelect.value);
const minute = minuteSelect.value;
const period = periodSelect.value;
// Convert 12-hour to 24-hour format
let cronHour;
if (period === 'AM') {
cronHour = hour === 12 ? 0 : hour;
} else {
cronHour = hour === 12 ? 12 : hour + 12;
}
// Create crontab format: "minute hour * * *"
const crontabValue = `${minute} ${cronHour} * * *`;
hiddenInput.value = crontabValue;
//console.log(`Updated crontab for ${key}: ${crontabValue}`);
}
// Toggle password visibility
static togglePasswordVisibility(fieldId) {
const passwordField = document.getElementById(fieldId);
const icon = document.getElementById(`${fieldId}-icon`);
if (!passwordField || !icon) {
console.warn(`Password field or icon not found: ${fieldId}`);
return;
}
if (passwordField.type === 'password') {
passwordField.type = 'text';
icon.textContent = '👁‍🗨'; // Eye with strikethrough (hidden)
} else {
passwordField.type = 'password';
icon.textContent = '👁'; // Regular eye (visible)
}
}
static setPasswordMode(fieldId, mode) {
const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`);
const input = document.getElementById(fieldId);
const tokenInput = document.getElementById(`${fieldId}-token`);
if (!wrapper || !input || !tokenInput) return;
const key = wrapper.dataset.fieldKey;
if (mode === 'random') {
input.dataset.previousCustom = input.value || '';
input.value = '';
input.readOnly = true;
input.type = 'password';
input.setAttribute('placeholder', 'Will generate on save');
input.removeAttribute('name');
tokenInput.setAttribute('name', key);
const icon = document.getElementById(`${fieldId}-icon`);
if (icon) icon.textContent = '👁';
} else {
input.readOnly = false;
input.removeAttribute('placeholder');
input.value = input.dataset.previousCustom || '';
input.setAttribute('name', key);
tokenInput.removeAttribute('name');
input.focus();
}
}
// Format config key to readable label
static formatConfigLabel(key) {
// Special handling for domain configuration
if (key.startsWith('CFG_DOMAIN_')) {
const domainNum = key.replace('CFG_DOMAIN_', '');
return `Domain ${domainNum}`;
}
// Special handling for requirement configuration - remove REQUIREMENT and format nicely
if (key.startsWith('CFG_REQUIREMENT_')) {
const requirement = key.replace('CFG_REQUIREMENT_', '');
return requirement
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
return key
.replace(/^CFG_/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
// Group config keys by category
static groupConfigKeys(config) {
const groups = {};
// Group by category field from JSON config
for (const key of Object.keys(config)) {
const configItem = config[key] || {};
const category = configItem.category;
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(key);
}
// Pin any per-app `*_BACKUP` toggle to the top of its category so the
// user always sees it first when configuring an app. Matches both
// `CFG_BACKUP` and `CFG_<APP>_BACKUP` (but not `*_BACKUP_*` like
// `CFG_BACKUP_CRONTAB_APP`, which would otherwise bubble up too).
for (const cat of Object.keys(groups)) {
groups[cat].sort((a, b) => {
const aBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(a);
const bBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(b);
if (aBackup && !bBackup) return -1;
if (!aBackup && bBackup) return 1;
return 0;
});
}
return groups;
}
// Extract category order from config
static extractCategoryOrder(config) {
const order = [];
const seen = new Set();
// Get categories in the order they appear in config
for (const key of Object.keys(config)) {
const configItem = config[key] || {};
const category = configItem.category;
if (category && !seen.has(category)) {
seen.add(category);
order.push(category);
}
}
return order;
}
// Format category name for display
static formatCategoryName(category) {
return category
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
/* Subcategory titles often come back as "<Category> <Sub>" — e.g.
"General Basic" under the General category, "Webui Logins" under
the Webui category. The redundant leading category word is noisy
when the user is already viewing that category page, so strip it.
Comparison is case-insensitive and only strips a clean leading
whole-word match (won't turn "Configuration" into "iguration"). */
static stripCategoryPrefix(title, parentCategory) {
if (!title || !parentCategory) return title;
const prefix = ConfigShared.formatCategoryName(parentCategory) + ' ';
if (title.toLowerCase().startsWith(prefix.toLowerCase())) {
return title.substring(prefix.length);
}
return title;
}
// Get category description from config or fallback
static async getCategoryDescription(category) {
try {
// First try to get description from the unified config
const response = await fetch('/data/config/generated/configs.json');
const data = await response.json();
// Check if categories exist in this config
if (data.categories) {
// Categories are stored as simple key-value pairs: "CATEGORY": "description"
const categoryDescription = data.categories[category];
if (categoryDescription) {
return categoryDescription;
}
}
// Fallback to unified config for general descriptions
const unifiedConfigResponse = await fetch('/data/config/generated/configs.json');
const unifiedConfigData = await unifiedConfigResponse.json();
const fallbackCategoryData = unifiedConfigData.categories[category] || null;
return fallbackCategoryData ? fallbackCategoryData.description : `${this.formatCategoryName(category)} settings and configuration`;
} catch (error) {
console.error('Error loading category descriptions:', error);
return `${this.formatCategoryName(category)} settings and configuration`;
}
}
// Generate appropriate field based on value type and key
static generateField(fieldId, key, value, title, description, options = {}, allConfig = {}) {
//console.log(`generateField: key=${key}, value=${value}, CFG_INSTALL_MODE=${allConfig.CFG_INSTALL_MODE?.value}`);
// Note: Git fields (CFG_GIT_*) are now handled by the toggle system in renderGitSection
// They don't need to be hidden here since the section itself is toggled
// Handle boolean toggle fields from options
const { onchange, oninput, onblur, className, placeholder, ...fieldOptions } = options;
// Check if value is boolean (true/false strings)
const isBoolean = value === 'true' || value === 'false';
if (isBoolean) {
// Auto-create toggle switch for boolean values
return this.createToggleSwitch(fieldId, key, value, title, description, {
type: 'standard',
layout: 'inline',
category: ''
});
}
// Non-boolean fields - use exact old config structure
const tooltipHtml = description ? `<span class="tooltip" title="${description}"></span>` : '';
let fieldHTML = `
<div class="field-group">
<label for="${fieldId}">
${title}
${tooltipHtml}
</label>
`;
// Determine field type based on key or options
////console.log(`Checking field type for key: ${key}`); // Debug log
////console.log(`Password check: ${key.includes('PASS') && !key.includes('LENGTH')}, ${key.includes('SECRET')}`); // Debug log
// Custom crontab fields for backup schedules
if (key === 'CFG_BACKUP_CRONTAB_APP') {
fieldHTML += this.createCrontabField(fieldId, key, value, title, description);
} else if (key.includes('PORT_RANGE')) {
fieldHTML += this.createRangeField(fieldId, key, value, title, description);
} else if ((key.includes('PASS') && !key.includes('LENGTH')) || key.includes('SECRET') || key.endsWith('_API_KEY') || key.endsWith('_PRIVATE_KEY')) {
////console.log(`Creating password field for: ${key}`); // Debug log
const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value);
const placeholderToken = randomMatch ? value : `RANDOMIZEDPASSWORD1`;
const initialMode = randomMatch ? 'random' : 'custom';
const inputValue = randomMatch ? '' : value;
const visibleName = initialMode === 'custom' ? `name="${key}"` : '';
const hiddenName = initialMode === 'random' ? `name="${key}"` : '';
fieldHTML += `
<div class="password-mode-wrapper" data-field-id="${fieldId}" data-field-key="${key}" data-placeholder-token="${placeholderToken}" style="display: flex; gap: 6px; align-items: stretch;">
<select class="form-control password-mode-select" style="max-width: 140px;" onchange="ConfigShared.setPasswordMode('${fieldId}', this.value)">
<option value="random" ${initialMode === 'random' ? 'selected' : ''}>Random</option>
<option value="custom" ${initialMode === 'custom' ? 'selected' : ''}>Custom</option>
</select>
<div class="password-input-wrapper" style="position: relative; flex: 1;">
<input type="password" id="${fieldId}" ${visibleName} value="${inputValue}" class="form-control password-field" style="padding-right: 35px;" ${initialMode === 'random' ? 'readonly placeholder="Will generate on save"' : ''}>
<input type="hidden" id="${fieldId}-token" ${hiddenName} value="${placeholderToken}">
<button class="password-toggle" type="button" onclick="togglePasswordVisibility('${fieldId}')" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); border: none; background: transparent; cursor: pointer; padding: 4px; border-radius: 3px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px;">
<span id="${fieldId}-icon" style="font-size: 14px; color: var(--text-primary); font-weight: bold; user-select: none;">👁</span>
</button>
</div>
</div>
<style>
.password-toggle {
opacity: 1 !important;
transition: all 0.2s ease;
}
.password-toggle:focus {
outline: none;
box-shadow: none;
}
.password-toggle:active {
transform: translateY(-50%) scale(0.95);
}
.password-toggle span {
opacity: 1 !important;
color: var(--text-primary) !important;
}
.password-toggle:hover {
background-color: rgba(var(--text-rgb), 0.12) !important;
}
.password-toggle:active {
background-color: rgba(var(--text-rgb), 0.22) !important;
}
</style>
`;
} else if (key.includes('EMAIL') || key.includes('MAIL')) {
// Special handling for CFG_MAIL_SECURE - it's a dropdown
if (key === 'CFG_MAIL_SECURE') {
const selectOptions = ConfigOptions.getSelectOptions(key);
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
} else {
// Different validation for different mail fields
let validationAttrs = '';
let fieldType = 'email';
let placeholder = 'user@example.com';
if (key === 'CFG_MAIL_HOST') {
fieldType = 'text';
placeholder = 'mail.domain.com';
validationAttrs = `onchange="window.configManager.validateHostnameFormat(this, true)" oninput="window.configManager.validateHostnameFormat(this, true)" onblur="window.configManager.validateHostnameFormat(this, true)"`;
} else if (key === 'CFG_MAIL_PORT') {
fieldType = 'number';
placeholder = '587';
validationAttrs = `onchange="window.configManager.validatePortNumber(this, true)" oninput="window.configManager.validatePortNumber(this, true)" onblur="window.configManager.validatePortNumber(this, true)"`;
} else if (key === 'CFG_MAIL_USERNAME' || key === 'CFG_MAIL_FROM') {
validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`;
} else {
validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`;
}
fieldHTML += `
<input type="${fieldType}" id="${fieldId}" name="${key}" value="${value}" class="form-control" placeholder="${placeholder}" ${validationAttrs}>
`;
}
} else if (key.includes('URL') || key.includes('LINK') || key.includes('HOST') || key.startsWith('CFG_DOMAIN_')) {
const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com';
fieldHTML += `
<input type="url" id="${fieldId}" name="${key}" value="${value}" class="form-control" placeholder="${placeholder}">
`;
} else if (/^CFG_BACKUP(_LOC_[0-9]+)?_KEEP_(LAST|DAILY|WEEKLY|MONTHLY|YEARLY)$/.test(key)) {
const unitLabel = key.endsWith('_KEEP_LAST') ? 'snapshots' :
key.endsWith('_KEEP_DAILY') ? 'days' :
key.endsWith('_KEEP_WEEKLY') ? 'weeks' :
key.endsWith('_KEEP_MONTHLY') ? 'months' : 'years';
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="9999">
<span class="input-group-text">${unitLabel}</span>
</div>
`;
} else if (key === 'CFG_BACKUP_VERIFY_DATA_PERCENT') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="1" max="100" placeholder="5">
<span class="input-group-text">%</span>
</div>
`;
} else if (key === 'CFG_UPDATER_CHECK') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="1" max="600" placeholder="60">
<span class="input-group-text">minutes</span>
</div>
`;
} else if (key === 'CFG_GENERATED_PASS_LENGTH') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="8" max="128" placeholder="26">
<span class="input-group-text">characters</span>
</div>
`;
} else if (key === 'CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="1440" placeholder="10">
<span class="input-group-text">minutes</span>
</div>
`;
} else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="1440" placeholder="60">
<span class="input-group-text">minutes</span>
</div>
`;
} else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC') {
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="1" max="10000" placeholder="200">
<span class="input-group-text">lines</span>
</div>
`;
} else if (key === 'CFG_SWAPFILE_SIZE') {
// Extract numeric value from "2G" format
let numericValue = value.replace(/[^0-9.]/g, '');
fieldHTML += `
<div class="input-group">
<input type="number" id="${fieldId}" name="${key}" value="${numericValue}" class="form-control" min="0" max="" placeholder="2">
<span class="input-group-text">GB</span>
</div>
`;
} else if (key.includes('SIZE') || key.includes('LENGTH') || key.includes('CHECK') || key.includes('MTU') || key.includes('PORT')) {
let min = '';
let max = '';
if (key.includes('PORT')) {
min = '1';
max = '65535';
} else if (key.includes('SIZE') || key.includes('LENGTH')) {
min = '0';
max = key.includes('PASS_LENGTH') ? '128' : '';
}
fieldHTML += `
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="${min}" max="${max}">
`;
} else if (key.includes('TIMEZONE')) {
// Special handling for Timezone - create comprehensive timezone dropdown
const timezoneOptions = ConfigOptions.getTimezoneOptions();
//console.log('Timezone key:', key, 'Current value:', value, 'Type:', typeof value);
//console.log('Available timezone options:', timezoneOptions.map(opt => ({value: opt.value, label: opt.label})));
fieldHTML += `
<select id="${fieldId}" name="${key}" class="form-control">
${timezoneOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}
</select>
`;
//console.log('Generated timezone dropdown HTML for', key, 'with value', value);
} else if (key === 'CFG_INSTALL_MODE') {
const selectOptions = ConfigOptions.getSelectOptions(key);
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control" onchange="handleInstallModeChange(this)">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
} else if (/^CFG_BACKUP_LOC_[0-9]+_TYPE$/.test(key)) {
const selectOptions = ConfigOptions.getSelectOptions(key);
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
} else if (ConfigOptions.isDropdownKey(key) || (options && Object.keys(options).length > 0)) {
//console.log('=== GENERIC DROPDOWN BLOCK ENTERED for key:', key);
//console.log('Dropdown detected for key:', key);
//console.log('isDropdownKey result:', ConfigOptions.isDropdownKey(key));
//console.log('options available:', options);
const selectOptions = (options && typeof options === 'string') ? this.parseOptions(options) : ConfigOptions.getSelectOptions(key);
//console.log('selectOptions:', selectOptions);
fieldHTML += `
<select id="${fieldId}" name="${key}" class="form-control">
${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}
</select>
`;
//console.log('Generated dropdown for', key, 'with value', value);
} else if (key.includes('DESCRIPTION') || key.includes('COMMENTS') || key.includes('NOTES')) {
//console.log('Textarea detected for key:', key);
fieldHTML += `
<textarea id="${fieldId}" name="${key}" class="form-control" rows="4">${value}</textarea>
`;
} else {
//console.log('Default text input for key:', key);
// Default text input with event handlers and options
const inputClass = className ? `form-control ${className}` : 'form-control';
const inputPlaceholder = placeholder || '';
const eventHandlers = [];
if (onchange) eventHandlers.push(`onchange="${onchange}"`);
if (oninput) eventHandlers.push(`oninput="${oninput}"`);
if (onblur) eventHandlers.push(`onblur="${onblur}"`);
const eventAttrs = eventHandlers.length > 0 ? ` ${eventHandlers.join(' ')}` : '';
fieldHTML += `
<input type="text" id="${fieldId}" name="${key}" value="${value}" class="${inputClass}" placeholder="${inputPlaceholder}"${eventAttrs}>
`;
}
fieldHTML += `
</div>
`;
return fieldHTML;
}
// Create master toggle for section enabling/disabling
static createMasterToggle(sectionId, masterKey, isEnabled, title, description) {
return `
<div class="master-toggle">
<div class="field-group">
<label class="checkbox-label">
<input type="checkbox" id="config-${masterKey}" name="${masterKey}" value="true" ${isEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSection('${sectionId}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${title}
${description ? `<span class="tooltip" title="${description}"></span>` : ''}
</span>
</label>
</div>
</div>
`;
}
// Create section content wrapper
static createSectionContent(sectionId, isEnabled) {
return `
<div id="section-content-${sectionId}" class="section-content ${isEnabled ? '' : 'disabled'}">
`;
}
// Toggle section fields (modular function)
static toggleSection(sectionId, isEnabled) {
const sectionContent = document.getElementById(`section-content-${sectionId}`);
if (!sectionContent) {
console.warn(`Section content not found: ${sectionId}`);
return;
}
const fields = sectionContent.querySelectorAll('input, select, textarea');
if (isEnabled) {
// Enable section
sectionContent.classList.remove('disabled');
fields.forEach(field => {
field.disabled = false;
const fieldGroup = field.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
//console.log(`Section ${sectionId} enabled`);
} else {
// Disable section
sectionContent.classList.add('disabled');
fields.forEach(field => {
field.disabled = true;
const fieldGroup = field.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '0.6';
fieldGroup.style.pointerEvents = 'none';
}
});
//console.log(`Section ${sectionId} disabled`);
}
}
// Toggle section visibility (hide/show entire section)
static toggleSectionVisibility(sectionId, isEnabled) {
const sectionContent = document.getElementById(sectionId);
if (!sectionContent) {
console.warn(`Section content not found: ${sectionId}`);
return;
}
if (isEnabled) {
// Show section
sectionContent.classList.remove('hidden');
//console.log(`Section ${sectionId} shown`);
} else {
// Hide section
sectionContent.classList.add('hidden');
//console.log(`Section ${sectionId} hidden`);
}
}
// Initialize section toggles on page load
static initializeSectionToggles() {
// Find all master toggles and initialize their sections
document.querySelectorAll('[id^="config-CFG_"][onchange*="toggleSection"]').forEach(toggle => {
// Extract section ID from the onchange attribute
const onchangeAttr = toggle.getAttribute('onchange');
const match = onchangeAttr.match(/toggleSection\('([^']+)'/);
if (match) {
const sectionId = match[1];
const isEnabled = toggle.checked;
this.toggleSection(sectionId, isEnabled);
}
});
}
// Git section toggle function (moved from global scope)
static toggleGitSectionFields(isEnabled) {
// If no parameter provided, get the state from the checkbox
if (typeof isEnabled === 'undefined') {
const gitLoginCheckbox = document.getElementById('git-login-toggle');
isEnabled = gitLoginCheckbox ? gitLoginCheckbox.checked : false;
}
const gitSectionContent = document.getElementById('git-section-content');
const gitFields = gitSectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
if (gitSectionContent && gitFields) {
if (isEnabled) {
gitSectionContent.classList.remove('hidden');
gitFields.forEach(field => {
field.disabled = false;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
} else {
gitSectionContent.classList.add('hidden');
gitFields.forEach(field => {
field.disabled = true;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '0.5';
fieldGroup.style.pointerEvents = 'none';
}
});
}
}
// Force re-initialization of form to ensure proper state
setTimeout(() => {
const form = document.getElementById('config-form');
if (form) {
// Trigger a change event to update any dependent fields
const event = new Event('change', { bubbles: true });
gitSectionContent?.dispatchEvent(event);
}
}, 100);
}
// Universal toggle function for all _ENABLED options
static toggleSection(sectionId, isEnabled) {
//console.log('=== UNIVERSAL TOGGLE DEBUG ===');
//console.log('sectionId:', sectionId);
//console.log('isEnabled:', isEnabled);
const sectionContent = document.getElementById(sectionId);
const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
//console.log('sectionContent found:', !!sectionContent);
//console.log('fields found:', fields ? fields.length : 0);
if (sectionContent && fields) {
if (isEnabled) {
//console.log('Enabling section...');
sectionContent.classList.remove('hidden');
fields.forEach((field, index) => {
//console.log(`Enabling field ${index}:`, field);
field.disabled = false;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
} else {
//console.log('Disabling section...');
sectionContent.classList.add('hidden');
fields.forEach((field, index) => {
//console.log(`Disabling field ${index}:`, field);
field.disabled = true;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '0.5';
fieldGroup.style.pointerEvents = 'none';
}
});
}
}
//console.log('=== UNIVERSAL TOGGLE DEBUG END ===');
}
// Remote backup section toggle function
static toggleMailSection(sectionId, isEnabled) {
//console.log('=== TOGGLE MAIL SECTION DEBUG ===');
//console.log('sectionId:', sectionId);
//console.log('isEnabled:', isEnabled);
alert('toggleMailSection called: ' + sectionId + ', enabled: ' + isEnabled);
//console.log('Looking for sectionContent...');
const sectionContent = document.getElementById(sectionId);
//console.log('sectionContent found:', !!sectionContent);
//console.log('Looking for mailFields...');
const mailFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
//console.log('mailFields found:', mailFields ? mailFields.length : 0);
if (sectionContent && mailFields) {
//console.log('Enabling mail section...');
if (isEnabled) {
//console.log('Removing hidden class...');
sectionContent.classList.remove('hidden');
//console.log('Enabling fields...');
mailFields.forEach((field, index) => {
//console.log(`Disabling field ${index}:`, field);
field.disabled = false;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
//console.log('Enabling field group...');
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
} else {
//console.log('Disabling mail section...');
sectionContent.classList.add('hidden');
//console.log('Disabling fields...');
mailFields.forEach((field, index) => {
//console.log(`Enabling field ${index}:`, field);
field.disabled = true;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
//console.log('Disabling field group...');
fieldGroup.style.opacity = '0.5';
fieldGroup.style.pointerEvents = 'none';
}
});
}
}
//console.log('=== TOGGLE MAIL SECTION DEBUG END ===');
}
static toggleRemoteBackupSection(sectionId, isEnabled) {
const sectionContent = document.getElementById(sectionId);
const backupFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
if (sectionContent && backupFields) {
if (isEnabled) {
sectionContent.classList.remove('hidden');
backupFields.forEach(field => {
field.disabled = false;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
} else {
sectionContent.classList.add('hidden');
backupFields.forEach(field => {
field.disabled = true;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '0.5';
fieldGroup.style.pointerEvents = 'none';
}
});
}
}
// Force re-initialization of form to ensure proper state
setTimeout(() => {
const form = document.getElementById('config-form');
if (form) {
// Trigger a change event to update any dependent fields
const event = new Event('change', { bubbles: true });
sectionContent?.dispatchEvent(event);
}
}, 100);
}
// Git section toggle function
static toggleGitSection(sectionId, isEnabled) {
const sectionContent = document.getElementById(sectionId);
const gitFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
if (sectionContent && gitFields) {
if (isEnabled) {
sectionContent.classList.remove('hidden');
gitFields.forEach(field => {
field.disabled = false;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '1';
fieldGroup.style.pointerEvents = 'auto';
}
});
} else {
sectionContent.classList.add('hidden');
gitFields.forEach(field => {
field.disabled = true;
const fieldGroup = field?.closest('.field-group');
if (fieldGroup) {
fieldGroup.style.opacity = '0.5';
fieldGroup.style.pointerEvents = 'none';
}
});
}
}
// Force re-initialization of form to ensure proper state
setTimeout(() => {
const form = document.getElementById('config-form');
if (form) {
// Trigger a change event to update any dependent fields
const event = new Event('change', { bubbles: true });
sectionContent?.dispatchEvent(event);
}
}, 100);
}
// Parse options string into array of {value, label} objects
static parseOptions(options) {
if (!options || typeof options !== 'string') {
return [];
}
return options.split('|').map(opt => {
const parts = opt.split('=');
if (parts.length === 2) {
return { value: parts[0].trim(), label: parts[1].trim() };
}
return { value: opt.trim(), label: opt.trim() };
});
}
// Filter helper — drops fields tagged with the **DEV** marker (in their
// description) when CFG_DEV_MODE is off. Keeps everything else verbatim.
// The marker convention (and the dev-mode flag) is shared with
// ConfigUtils — see cleanDescription / isDevField / isDevModeOn.
static _filterDevKeys(keys, config) {
const cfg = (window.systemConfigs || {});
const devOn = (cfg.CFG_DEV_MODE === 'true' || cfg.CFG_DEV_MODE === true);
if (devOn) return keys;
return keys.filter(key => {
const desc = (config[key] && config[key].description) || '';
return !desc.includes('**DEV**');
});
}
// Generate fields for category with 3-per-line layout and smart field detection
static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) {
keys = this._filterDevKeys(keys, config);
let formHTML = '<div class="domains-divider"></div><div class="config-fields">';
keys.forEach((key, index) => {
const configItem = config[key] || {};
const value = configItem.value || '';
const title = configItem.title || this.formatConfigLabel(key);
const description = configItem.description || '';
const options = configItem.options || '';
const fieldId = `config-${key}`;
// Add line break every 3 items
if (index > 0 && index % 3 === 0) {
formHTML += `</div><div class="config-fields">`;
}
// Use smart field creation if no callback provided, otherwise use callback
if (generateFieldCallback) {
formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config);
} else {
formHTML += this.createSmartField(fieldId, key, value, title, description, {
selectOptions: options,
category: category,
layout: 'inline',
config: config
});
}
});
formHTML += `</div>`;
return formHTML;
}
// Generate fields for category WITHOUT the leading divider (for master toggle sections)
static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) {
keys = this._filterDevKeys(keys, config);
let formHTML = '';
keys.forEach((key, index) => {
const configItem = config[key] || {};
const value = configItem.value || '';
const title = configItem.title || this.formatConfigLabel(key);
const description = configItem.description || '';
const options = configItem.options || '';
const fieldId = `config-${key}`;
let fieldHTML;
if (generateFieldCallback) {
fieldHTML = generateFieldCallback(fieldId, key, value, title, description, options, config);
} else {
fieldHTML = this.generateField(fieldId, key, value, title, description, options, config);
}
formHTML += fieldHTML;
});
return formHTML;
}
// Separate categories into regular, advanced, and unused
static categorizeConfigs(config) {
const groupedConfigs = this.groupConfigKeys(config);
const categoryOrder = this.extractCategoryOrder(config);
const regularCategories = [];
const advancedCategories = [];
const unusedCategories = [];
for (const category of categoryOrder) {
const keys = groupedConfigs[category];
if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') {
// Check if category has advanced items
const hasAdvanced = keys.some(key => {
const configItem = config[key] || {};
return configItem.advanced === true;
});
// Check if category has unused items
const hasUnused = keys.some(key => {
const configItem = config[key] || {};
return configItem.unused === true;
});
// Categorize based on the presence of advanced/unused flags
if (hasUnused) {
unusedCategories.push(category);
} else if (hasAdvanced) {
advancedCategories.push(category);
} else {
regularCategories.push(category);
}
}
}
return {
groupedConfigs,
categoryOrder,
regularCategories,
advancedCategories,
unusedCategories
};
}
// Generate warning notice for requirements page
static generateRequirementsWarning() {
return `
<div class="requirements-warning-section">
<div class="requirements-warning-header">
<h3>⚠️ System Requirements Warning</h3>
<p>Disabling any of the following system requirements may break LibrePortal functionality. Always create a backup before making changes.</p>
</div>
</div>
`;
}
// Generate toggle controls HTML for advanced/unused sections
static generateToggleControls(hasAdvanced = false, hasUnused = false) {
if (!hasAdvanced && !hasUnused) {
return ''; // Don't show danger zone if no content
}
let formHTML = `
<div class="danger-zone-section">
<div class="danger-zone-header">
<h3>⚠️ Danger Zone</h3>
<p>These options are for advanced users and may affect system stability</p>
</div>
<div class="config-toggles">
`;
if (hasAdvanced) {
formHTML += `
<div class="toggle-section">
<label class="checkbox-label">
<input type="checkbox" id="show-advanced" onchange="ConfigShared.toggleAdvancedSections()">
<span class="checkbox-custom"></span>
<div class="toggle-content">
<span class="checkbox-text">Show Advanced Options</span>
<span class="checkbox-description">Display advanced configuration settings for experienced users</span>
</div>
</label>
</div>
`;
}
if (hasUnused) {
formHTML += `
<div class="toggle-section">
<label class="checkbox-label">
<input type="checkbox" id="show-unused" onchange="ConfigShared.toggleUnusedSections()">
<span class="checkbox-custom"></span>
<div class="toggle-content">
<span class="checkbox-text">Show Unused Options</span>
<span class="checkbox-description">Display deprecated or unused configuration options</span>
</div>
</label>
</div>
`;
}
formHTML += `
</div>
</div>
`;
return formHTML;
}
// Generate advanced sections HTML
static async generateAdvancedSections(advancedCategories, groupedConfigs, config, getCategoryDescriptionCallback) {
if (advancedCategories.length === 0) {
return '';
}
let formHTML = `
<div id="advanced-sections" class="advanced-sections" style="display: none;">
<div class="section-divider">
<h3>🛠️ Advanced Configuration</h3>
<p>Advanced settings for experienced users</p>
</div>
`;
for (const category of advancedCategories) {
const keys = groupedConfigs[category];
if (keys && keys.length > 0) {
const displayCategory = this.formatCategoryName(category);
const categoryDescription = getCategoryDescriptionCallback ? await getCategoryDescriptionCallback(category) : '';
// Check if this category has a master toggle (any key with master: true)
const masterKey = keys.find(key => {
const configItem = config[key] || {};
return configItem.master === true;
});
if (masterKey) {
// Dynamic master toggle handling for advanced sections
const masterValue = config[masterKey]?.value || 'false';
const isMasterEnabled = masterValue === 'true';
const masterTitle = config[masterKey]?.title || this.formatConfigLabel(masterKey);
const masterDescription = config[masterKey]?.description || '';
const sectionId = `${category.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const toggleId = `${masterKey.toLowerCase()}-toggle`;
formHTML += `
<div class="config-section advanced-section">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
<div class="domains-divider"></div>
<div class="git-master-toggle">
<div class="field-group">
<label class="checkbox-label master-toggle">
<input type="checkbox" id="${toggleId}" name="${masterKey}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${masterTitle}
<span class="tooltip" title="${masterDescription}"></span>
</span>
</label>
</div>
</div>
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
<div class="config-fields">
${this.generateFieldsForCategoryNoDivider(keys.filter(key => key !== masterKey), category, config)}
</div>
</div>
</div>
`;
} else {
// Regular advanced section handling (no master toggle)
formHTML += `
<div class="config-section advanced-section">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
<div class="config-group">
${this.generateFieldsForCategory(keys, category, config)}
</div>
</div>
`;
}
}
}
formHTML += `</div>`;
return formHTML;
}
// Generate unused sections HTML
static async generateUnusedSections(unusedCategories, groupedConfigs, config, getCategoryDescriptionCallback) {
if (unusedCategories.length === 0) {
return '';
}
let formHTML = `
<div id="unused-sections" class="unused-sections" style="display: none;">
<div class="section-divider">
<h3>🗑️ Unused Configuration</h3>
<p>Deprecated or unused configuration options</p>
</div>
`;
for (const category of unusedCategories) {
const keys = groupedConfigs[category];
if (keys && keys.length > 0) {
const displayCategory = this.formatCategoryName(category);
const categoryDescription = getCategoryDescriptionCallback ? await getCategoryDescriptionCallback(category) : '';
// Check if this category has a master toggle (any key with master: true)
const masterKey = keys.find(key => {
const configItem = config[key] || {};
return configItem.master === true;
});
if (masterKey) {
// Dynamic master toggle handling for unused sections
const masterValue = config[masterKey]?.value || 'false';
const isMasterEnabled = masterValue === 'true';
const masterTitle = config[masterKey]?.title || this.formatConfigLabel(masterKey);
const masterDescription = config[masterKey]?.description || '';
const sectionId = `${category.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const toggleId = `${masterKey.toLowerCase()}-toggle`;
formHTML += `
<div class="config-section unused-section">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
<div class="domains-divider"></div>
<div class="git-master-toggle">
<div class="field-group">
<label class="checkbox-label master-toggle">
<input type="checkbox" id="${toggleId}" name="${masterKey}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${masterTitle}
<span class="tooltip" title="${masterDescription}"></span>
</span>
</label>
</div>
</div>
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
<div class="config-fields">
${this.generateFieldsForCategoryNoDivider(keys.filter(key => key !== masterKey), category, config)}
</div>
</div>
</div>
`;
} else {
// Regular unused section handling (no master toggle)
formHTML += `
<div class="config-section unused-section">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
<div class="config-group">
${this.generateFieldsForCategory(keys, category, config)}
</div>
</div>
`;
}
}
}
formHTML += `</div>`;
return formHTML;
}
// Toggle advanced sections visibility
static toggleAdvancedSections() {
const checkbox = document.getElementById('show-advanced');
const advancedSections = document.getElementById('advanced-sections');
if (advancedSections) {
advancedSections.style.display = checkbox.checked ? 'block' : 'none';
}
}
// Toggle unused sections visibility
static toggleUnusedSections() {
const checkbox = document.getElementById('show-unused');
const unusedSections = document.getElementById('unused-sections');
if (unusedSections) {
unusedSections.style.display = checkbox.checked ? 'block' : 'none';
}
}
}
// Export for global access
window.ConfigShared = ConfigShared;
// Global toggle change function for checkbox handling
window.handleToggleChange = function(checkbox, key) {
//console.log(`Toggle changed: ${key} = ${checkbox.checked}`);
// This function can be extended to handle specific toggle logic
// For now, it just logs change
};
// Global flag to prevent multiple config reloads
window.isReloadingConfig = false;
// Global function to handle CFG_INSTALL_MODE change
window.handleInstallModeChange = function(selectElement) {
// Prevent multiple simultaneous reloads
if (window.isReloadingConfig) {
//console.log('Config reload already in progress, ignoring...');
return;
}
const installMode = selectElement.value;
//console.log(`Install mode changed to: ${installMode}`);
// Set flag to prevent multiple reloads
window.isReloadingConfig = true;
// Use a longer delay and onchange instead of onblur to avoid the loop
setTimeout(() => {
if (window.configManager) {
// Clear cache to ensure we get fresh data with updated CFG_INSTALL_MODE
window.configManager.cache.clear();
//console.log('Cache cleared for fresh config data');
// Get the current category from the URL or default to 'general'
const currentCategory = window.configCategory || 'general';
//console.log(`Reloading config category: ${currentCategory}`);
window.configManager.renderConfig(currentCategory).finally(() => {
// Clear the flag after reload is complete
setTimeout(() => {
window.isReloadingConfig = false;
}, 500); // Extra delay to ensure DOM is fully settled
});
} else if (window.configRouter) {
// Fallback to configRouter if configManager is not available
const currentCategory = window.configCategory || 'general';
//console.log(`Using configRouter to reload: ${currentCategory}`);
window.configRouter.loadConfigComponentManual(currentCategory).finally(() => {
// Clear the flag after reload is complete
setTimeout(() => {
window.isReloadingConfig = false;
}, 500); // Extra delay to ensure DOM is fully settled
});
} else {
console.warn('Neither configManager nor configRouter available for install mode change');
window.isReloadingConfig = false;
}
}, 500); // Longer delay to avoid conflicts
};
// Global function to initialize git field visibility based on current CFG_INSTALL_MODE
window.initializeGitFieldVisibility = function() {
const installModeSelect = document.querySelector('select[name="CFG_INSTALL_MODE"]');
if (installModeSelect) {
// Trigger the change handler to set initial visibility
handleInstallModeChange(installModeSelect);
}
};
// Global password toggle function for onclick handlers
window.togglePasswordVisibility = function(fieldId) {
ConfigShared.togglePasswordVisibility(fieldId);
};