librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

1646 lines
67 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.

// Simple Config Manager - Direct approach without complex dependencies
class ConfigManager {
constructor() {
this.cache = new Map();
}
getRandomLoadingMessage() {
const messages = [
"Preparing your configuration settings...",
"Gathering the finest configuration options...",
"Tuning up your system preferences...",
"Organizing your configuration categories...",
"Loading the perfect settings for you...",
"Crafting your personalized configuration...",
"Aligning your configuration stars...",
"Brewing the ideal configuration blend...",
"Setting up your configuration masterpiece...",
"Polishing your configuration preferences...",
"Configuring things just right for you...",
"Preparing your digital control panel...",
"Gathering your system's best settings...",
"Optimizing your configuration experience...",
"Loading your configuration superpowers..."
];
return messages[Math.floor(Math.random() * messages.length)];
}
async loadConfig(category) {
//console.log(`ConfigManager: Loading ${category} config...`);
// Check cache first
if (this.cache.has('unified')) {
//console.log(`ConfigManager: Using cached unified config`);
const unifiedData = this.cache.get('unified');
return this.filterConfigByCategory(unifiedData, category);
}
try {
// Load unified config data
const response = await fetch('/data/config/generated/configs.json');
if (!response.ok) {
throw new Error(`Failed to load configs.json: ${response.status}`);
}
const configData = await response.json();
//console.log(`ConfigManager: Loaded unified config:`, configData);
// Cache the result
this.cache.set('unified', configData);
// Filter by requested category
const categoryData = this.filterConfigByCategory(configData, category);
//console.log(`ConfigManager: Filtered ${category} config:`, categoryData);
return categoryData;
} catch (error) {
console.error(`ConfigManager: Error loading ${category} config:`, error);
return { config: {}, description: 'Failed to load configuration' };
}
}
filterConfigByCategory(unifiedData, category) {
if (!unifiedData || !unifiedData.config) {
return { config: {}, categories: {} };
}
// Filter config items by category
const filteredConfig = {};
Object.entries(unifiedData.config).forEach(([key, value]) => {
if (value.category === category) {
filteredConfig[key] = value;
}
});
return {
config: filteredConfig,
categories: unifiedData.categories || {},
subcategories: unifiedData.subcategories || {},
configType: unifiedData.configType,
name: unifiedData.name
};
}
async renderConfig(category) {
//console.log(`ConfigManager: Rendering ${category} config...`);
const configSection = document.getElementById('config-section');
if (!configSection) {
console.error('ConfigManager: config-section element not found');
return;
}
// Show loading with enhanced visual
configSection.innerHTML = `
<div class="loading-enhanced" style="padding: 22px;">
<div class="loading-content" style="
text-align: center;
padding: 22px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
">
<div class="loading-spinner" style="
width: 24px;
height: 24px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px auto;
display: inline-block;
"></div>
<div class="loading-message" style="
font-size: 16px;
color: var(--text-primary, #fff);
font-weight: 500;
margin-bottom: 8px;
">
Loading configuration...
</div>
<div class="loading-subtitle" style="
font-size: 14px;
color: var(--text-color, #fff);
font-style: italic;
">
${this.getRandomLoadingMessage()}
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</div>
`;
// Update loading bar if available
if (typeof router !== 'undefined' && router.updateProgress) {
router.updateProgress(60);
}
try {
// Load config data
const configData = await this.loadConfig(category);
const config = configData.config || {};
// Update loading bar
if (typeof router !== 'undefined' && router.updateProgress) {
router.updateProgress(70);
}
if (Object.keys(config).length === 0) {
configSection.innerHTML = '<div class="error">No configuration available</div>';
return;
}
// Use the original ConfigShared system for beautiful rendering
if (typeof ConfigShared !== 'undefined') {
await this.renderWithOriginalStyling(category, configData);
} else {
// Fallback: load ConfigShared and then render
if (typeof router !== 'undefined' && router.updateProgress) {
router.updateProgress(75);
}
await this.loadScript('/js/components/config/config-options.js');
await this.loadScript('/js/components/config/config-shared.js');
await this.renderWithOriginalStyling(category, configData);
}
// Final progress update
if (typeof router !== 'undefined' && router.updateProgress) {
router.updateProgress(80);
}
//console.log(`ConfigManager: Successfully rendered ${category} config`);
// Initialize git field visibility after rendering
if (typeof initializeGitFieldVisibility === 'function') {
setTimeout(() => {
initializeGitFieldVisibility();
}, 100);
}
} catch (error) {
console.error(`ConfigManager: Error rendering ${category} config:`, error);
configSection.innerHTML = `<div class="error">Failed to load ${category} configuration: ${error.message}</div>`;
}
}
async renderWithOriginalStyling(category, configData) {
//console.log(`renderWithOriginalStyling: category=${category}, CFG_INSTALL_MODE=${configData.config?.CFG_INSTALL_MODE?.value}`);
const configSection = document.getElementById('config-section');
const config = configData.config || {};
const subcategories = configData.subcategories || {};
// Check if we have subcategories data available
const hasSubcategories = Object.keys(subcategories).length > 0;
// Use shared categorization functionality
const categorized = ConfigShared.categorizeConfigs(config);
const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized;
// Render using the original system's approach with advanced/unused sections
let formHTML = `
<div class="config-title">
<h3>${this.formatCategoryName(category)} Configuration</h3>
<p>${configData.description || 'Configure settings for ' + category}</p>
</div>
<div class="config-container">
<form id="config-form-${category}" class="config-form">
`;
// Add requirements warning for requirements category
if (category === 'requirements') {
formHTML += ConfigShared.generateRequirementsWarning();
}
// Add danger zone warning for features category
if (category === 'features') {
formHTML += `
<div class="danger-zone-banner">
<div class="danger-zone-content">
<span class="danger-zone-icon">⚠️</span>
<div class="danger-zone-text">
<strong>Danger Zone</strong> - These options are for advanced users and may affect system stability
</div>
</div>
</div>
`;
}
// Render using subcategories structure if available, otherwise fall back to original
if (hasSubcategories) {
// Render using new subcategories structure
const regularSubcategories = [];
const advancedSubcategories = [];
const unusedSubcategories = [];
// Filter subcategories by category and separate into regular, advanced, and unused
for (const [subcategoryName, subcategoryData] of Object.entries(subcategories)) {
if (subcategoryData.category === category) {
if (subcategoryData.description.includes('**ADVANCED**')) {
advancedSubcategories.push(subcategoryName);
} else if (subcategoryData.description.includes('**UNUSED**')) {
unusedSubcategories.push(subcategoryName);
} else {
regularSubcategories.push(subcategoryName);
}
}
}
// Render regular subcategories with proper sectioning
for (const subcategoryName of regularSubcategories) {
const subcategoryData = subcategories[subcategoryName];
const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName);
const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category);
const subcategoryDescription = subcategoryData.description || 'Subcategory configuration';
// Find config items for this subcategory
const configItems = Object.entries(config)
.filter(([key, value]) => value.subcategory === subcategoryName)
.map(([key, value]) => ({ key, ...value }));
if (configItems.length > 0) {
// Check for master toggle in this subcategory
const masterKey = configItems.find(item => item.master === true);
// Find any ENABLED options and use universal toggle renderer
const enabledKey = configItems.find(item => item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE');
//console.log('ConfigManager: Checking for toggle - subcategoryName:', subcategoryName, 'enabledKey found:', !!enabledKey, enabledKey ? enabledKey.key : null);
// Special handling for domains section
const isDomains = subcategoryName.includes('domains');
if (enabledKey) {
// Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE
formHTML += ToggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription, config);
} else if (masterKey) {
// Render with master toggle
formHTML += this.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription);
} else if (isDomains) {
// Render domains section with special handling
formHTML += await this.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription);
} else {
// Render regular subcategory with proper sectioning
formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config);
}
}
}
// Render advanced subcategories (hidden by default)
if (advancedSubcategories.length > 0) {
// Add danger zone toggle for advanced sections
formHTML += ConfigShared.generateToggleControls(true, false);
// Generate advanced sections using shared functionality
const advancedGroupedConfigs = {};
advancedSubcategories.forEach(subcategoryName => {
const configItems = Object.entries(config)
.filter(([key, value]) => value.subcategory === subcategoryName)
.map(([key, value]) => ({ key, ...value }));
if (configItems.length > 0) {
advancedGroupedConfigs[subcategoryName] = configItems.map(item => item.key);
}
});
formHTML += await ConfigShared.generateAdvancedSections(
advancedSubcategories,
advancedGroupedConfigs,
config,
(category) => this.cleanDescription(subcategories[category]?.description || 'Advanced settings')
);
}
// Render unused subcategories (hidden by default)
if (unusedSubcategories.length > 0) {
// Add unused section toggle
formHTML += ConfigShared.generateToggleControls(false, true);
// Wrap unused sections in hidden container
formHTML += `
<div id="unused-sections" class="unused-sections" style="display: none;">
<div class="section-divider">
<h3>🗑️ Unused Configuration</h3>
<p>Deprecated or unused settings</p>
</div>
`;
// Generate unused sections
for (const subcategoryName of unusedSubcategories) {
const subcategoryData = subcategories[subcategoryName];
const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName);
const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category);
const subcategoryDescription = this.cleanDescription(subcategoryData.description || 'Unused configuration');
// Find config items for this subcategory
const configItems = Object.entries(config)
.filter(([key, value]) => value.subcategory === subcategoryName)
.map(([key, value]) => ({ key, ...value }));
if (configItems.length > 0) {
// Check if this is a backup section that needs toggle
const isRemoteBackup = subcategoryName.includes('remote_backup');
const isFullBackup = subcategoryName.includes('full_backup');
const backupKey = (isRemoteBackup || isFullBackup) ? configItems.find(item => item.key.includes('ENABLED')) : null;
// Check if this is a mail section that needs toggle
const isMail = subcategoryName.includes('mail_server');
const mailKey = isMail ? configItems.find(item => item.key.includes('ENABLED')) : null;
// Check if this is a Git section that needs toggle
const isGit = subcategoryName.includes('install');
const gitKey = isGit ? configItems.find(item => item.key === 'CFG_INSTALL_MODE') : null;
if (backupKey) {
// Render backup section with special toggle
formHTML += this.renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config);
} else if (mailKey) {
// Render mail section with special toggle
formHTML += this.renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config);
} else if (gitKey) {
// Render Git section with special toggle
formHTML += this.renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config);
} else {
// Render regular unused section
formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, subcategoryName);
}
}
}
// Close the unused sections container
formHTML += `
</div>
`;
}
} else {
// Fall back to original categorization system
const categorized = ConfigShared.categorizeConfigs(config);
const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized;
// Render regular categories (always visible)
for (const cat of regularCategories) {
const keys = groupedConfigs[cat];
if (keys && keys.length > 0 && cat !== 'Hidden/Unused Options') {
const displayCategory = ConfigShared.formatCategoryName(cat);
const categoryDescription = await ConfigShared.getCategoryDescription(cat);
// 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
const masterValue = config[masterKey]?.value || 'false';
const isMasterEnabled = masterValue === 'true';
const masterTitle = config[masterKey]?.title || ConfigShared.formatConfigLabel(masterKey);
const masterDescription = config[masterKey]?.description || '';
const sectionId = `${cat.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const toggleId = `${masterKey.toLowerCase()}-toggle`;
formHTML += `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
</div>
</div>
<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">
`;
// Add all other fields (excluding the master toggle)
keys.filter(key => key !== masterKey).forEach(key => {
const configItem = config[key] || {};
const value = configItem.value || '';
const title = configItem.title || ConfigShared.formatConfigLabel(key);
const description = configItem.description || '';
const options = configItem.options || '';
const fieldId = `config-${key}`;
formHTML += ConfigShared.generateField(fieldId, key, value, title, description, options, config);
});
formHTML += `
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
} else if (cat === 'DOMAINS') {
// Check if Traefik is installed
const traefikInstalled = await this.checkTraefikInstallation();
// Always show the domains section, but add a warning banner if Traefik is not installed
formHTML += `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
</div>
<div class="domains-divider"></div>
`;
if (!traefikInstalled) {
// Show smaller warning banner
formHTML += `
<div class="traefik-warning-banner">
<div class="traefik-warning-content">
<span class="traefik-warning-icon">⚠️</span>
<div class="traefik-warning-text">
<strong>Traefik Not Installed</strong> - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later.
</div>
</div>
</div>
`;
}
formHTML += `<div class="domain-building-blocks">`;
// Only show domains that have content (non-empty values)
const allDomainKeys = keys.filter(key => key.startsWith('CFG_DOMAIN_'));
const domainKeysWithContent = allDomainKeys.filter(key => {
const configItem = config[key] || {};
const value = configItem.value || '';
return value.trim() !== '';
});
// Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots)
const isMaxDomains = domainKeysWithContent.length >= 9;
domainKeysWithContent.forEach(key => {
const configItem = config[key] || {};
const value = configItem.value || '';
const title = configItem.title || ConfigShared.formatConfigLabel(key);
const fieldId = `config-${key}`;
// Extract domain number
const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]);
const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k =>
parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1])
));
// Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted
const canDelete = isHighestDomain && domainNum !== 1;
formHTML += `
<div class="domain-building-block">
<div class="domain-header">
${ConfigShared.generateField(fieldId, key, value, title, '', {
placeholder: 'example.com',
className: 'domain-input'
})}
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
onclick="window.configManager.deleteDomain('${key}', this)"
title="${canDelete ? 'Delete domain' : domainNum === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'}"
${!canDelete ? 'disabled' : ''}>
<span class="delete-icon">×</span>
</button>
</div>
</div>
`;
});
formHTML += `
</div>
<div class="domain-actions">
<button type="button" id="add-domain-btn" class="btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}"
onclick="window.configManager.addNewDomain()"
${isMaxDomains ? 'disabled' : ''}>
<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>
${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}
</button>
</div>
</div>
</div>
`;
} else {
// Regular category handling (no master toggle)
formHTML += `
<div class="config-category">
<h3>${displayCategory}</h3>
<p class="category-description">${categoryDescription}</p>
<div class="config-group">
${ConfigShared.generateFieldsForCategory(keys, cat, config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))}
</div>
</div>
`;
}
}
}
}
// Add danger zone before advanced/unused sections (so content appears below)
formHTML += ConfigShared.generateToggleControls(
advancedCategories.length > 0,
unusedCategories.length > 0
);
// Add advanced and unused sections using shared functionality
formHTML += await ConfigShared.generateAdvancedSections(
advancedCategories,
groupedConfigs,
config,
(category) => ConfigShared.getCategoryDescription(category)
);
formHTML += await ConfigShared.generateUnusedSections(
unusedCategories,
groupedConfigs,
config,
(category) => ConfigShared.getCategoryDescription(category)
);
formHTML += `
</form>
<div class="config-actions">
<button type="button" class="btn btn-primary" onclick="ConfigManager.saveConfig('${category}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
Save Configuration
</button>
<button type="button" class="btn btn-secondary" onclick="ConfigManager.resetConfig('${category}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
Reset to Defaults
</button>
</div>
</div>
`;
configSection.innerHTML = formHTML;
// Initialize all master toggles dynamically
setTimeout(() => {
// Find all master toggle checkboxes (any input with id ending in "-toggle" where the name ends with "_ENABLED")
const masterToggles = document.querySelectorAll('input[id$="-toggle"][name$="_ENABLED"]');
masterToggles.forEach(toggle => {
const sectionId = toggle.id.replace('-toggle', '');
const section = document.getElementById(`section-content-${sectionId}`);
if (section && typeof ConfigShared.toggleSectionVisibility === 'function') {
// Initialize the section state based on the toggle
ConfigShared.toggleSectionVisibility(`section-content-${sectionId}`, toggle.checked);
}
});
}, 100);
}
// Check if Traefik is installed
async checkTraefikInstallation() {
try {
// Use the generic app installation checker
return await DataLoader.isAppInstalled('traefik');
} catch (error) {
//console.log('Traefik check failed:', error.message);
return false;
}
}
// Domain management functions
addNewDomain() {
//console.log('Add Domain button clicked!');
try {
// Find the highest existing domain number
const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]');
const domainNumbers = [];
domainInputs.forEach(input => {
const match = input.name.match(/CFG_DOMAIN_(\d+)/);
if (match) {
domainNumbers.push(parseInt(match[1]));
}
});
// Check if we've reached the maximum of 9 domains
if (domainNumbers.length >= 9) {
//console.log('Maximum of 9 domains reached');
return;
}
const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1;
const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`;
const newFieldId = `config-${newDomainKey}`;
// Create new domain building block HTML
const newDomainHTML = `
<div class="domain-building-block">
<div class="domain-header">
${ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })}
<button type="button" class="delete-domain-btn"
onclick="window.configManager.deleteDomain('${newDomainKey}', this)">
<span class="delete-icon">×</span>
</button>
</div>
</div>
`;
// Find the domain-building-blocks container and add the new block
const domainContainer = document.querySelector('.domain-building-blocks');
if (domainContainer) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newDomainHTML;
const newBlock = tempDiv.firstElementChild;
domainContainer.appendChild(newBlock);
// Focus on the new input field
const newInput = newBlock.querySelector('input');
if (newInput) {
newInput.focus();
}
// Update add domain button state
const addBtn = document.getElementById('add-domain-btn');
if (addBtn) {
const totalDomains = document.querySelectorAll('.domain-building-block').length;
if (totalDomains >= 9) {
addBtn.disabled = true;
addBtn.className = 'btn btn-secondary';
addBtn.innerHTML = '<span class="add-icon">✓</span>Maximum Domains Reached';
}
}
// Update delete button states (only highest numbered domain should be deletable)
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
allDomainBlocks.forEach((block, index) => {
const deleteBtn = block.querySelector('.delete-domain-btn');
if (deleteBtn) {
const input = block.querySelector('input');
const inputName = input ? input.name : '';
const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/);
const domainNumber = domainNum ? parseInt(domainNum[1]) : 0;
// Find the highest domain number among all visible blocks
const allDomainNumbers = Array.from(allDomainBlocks).map(b => {
const inp = b.querySelector('input');
const name = inp ? inp.name : '';
const match = name.match(/CFG_DOMAIN_(\d+)/);
return match ? parseInt(match[1]) : 0;
});
const highestDomainNumber = Math.max(...allDomainNumbers);
// Only highest numbered domain can be deleted, but NEVER Domain 1
const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1;
deleteBtn.disabled = !canDelete;
deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`;
deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain';
}
});
}
} catch (error) {
console.error('Error adding new domain:', error);
}
}
deleteDomain(domainKey, buttonElement) {
//console.log(`Delete domain button clicked for: ${domainKey}`);
try {
// Find the domain-building-block and remove it
const domainBlock = buttonElement.closest('.domain-building-block');
if (domainBlock) {
// Clear the input value first
const input = domainBlock.querySelector('input');
if (input) {
input.value = '';
}
// Remove the entire building block
domainBlock.remove();
// Update add domain button state (re-enable if we're below 9 domains)
const addBtn = document.getElementById('add-domain-btn');
if (addBtn) {
const totalDomains = document.querySelectorAll('.domain-building-block').length;
if (totalDomains < 9) {
addBtn.disabled = false;
addBtn.className = 'btn btn-primary';
addBtn.innerHTML = '<span class="add-icon">+</span>Add Domain';
}
}
// Update delete button states (only highest numbered domain should be deletable)
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
allDomainBlocks.forEach((block, index) => {
const deleteBtn = block.querySelector('.delete-domain-btn');
if (deleteBtn) {
const input = block.querySelector('input');
const inputName = input ? input.name : '';
const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/);
const domainNumber = domainNum ? parseInt(domainNum[1]) : 0;
// Find the highest domain number among all visible blocks
const allDomainNumbers = Array.from(allDomainBlocks).map(b => {
const inp = b.querySelector('input');
const name = inp ? inp.name : '';
const match = name.match(/CFG_DOMAIN_(\d+)/);
return match ? parseInt(match[1]) : 0;
});
const highestDomainNumber = Math.max(...allDomainNumbers);
// Only highest numbered domain can be deleted, but NEVER Domain 1
const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1;
deleteBtn.disabled = !canDelete;
deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`;
deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain';
}
});
}
} catch (error) {
console.error('Error deleting domain:', error);
}
}
async loadScript(src) {
const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_');
const existingScript = document.getElementById(scriptId);
if (existingScript && src.includes('config-shared.js')) {
existingScript.remove();
} else if (existingScript) {
return;
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.id = scriptId;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
});
}
formatCategoryName(category) {
return category
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
static async saveConfig(category) {
const form = document.getElementById(`config-form-${category}`);
if (!form) return;
const formData = new FormData(form);
const config = {};
for (const [key, value] of formData.entries()) {
const checkbox = form.querySelector(`input[name="${key}"][type="checkbox"]`);
if (checkbox) {
config[key] = checkbox.checked;
} else {
config[key] = value;
}
}
}
static async resetConfig(category) {
if (confirm('Are you sure you want to reset all settings to their default values?')) {
window.location.reload();
}
}
// Helper method to render subcategory with master toggle
renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription, isAdvanced = false) {
const masterValue = masterKey.value || 'false';
const isMasterEnabled = masterValue === 'true';
const masterTitle = masterKey.title || ConfigShared.formatConfigLabel(masterKey.key);
const masterDescription = masterKey.description || '';
const sectionId = `${displaySubcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const toggleId = `${masterKey.key.toLowerCase()}-toggle`;
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
</div>
<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.key}" 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">
`;
// Add all other fields (excluding the master toggle)
configItems.filter(item => item.key !== masterKey.key).forEach(item => {
const fieldId = `config-${item.key}`;
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
});
html += `
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
// Helper method to render domains section with special handling
async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) {
// Check if Traefik is installed
const traefikInstalled = await this.checkTraefikInstallation();
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
<div class="domains-divider"></div>
`;
if (!traefikInstalled) {
// Show smaller warning banner
html += `
<div class="traefik-warning-banner">
<div class="traefik-warning-content">
<span class="traefik-warning-icon">⚠️</span>
<div class="traefik-warning-text">
<strong>Traefik Not Installed</strong> - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later.
</div>
</div>
</div>
`;
}
html += `<div class="domain-building-blocks">`;
// Only show domains that have content (non-empty values)
const allDomainKeys = configItems.filter(item => item.key.startsWith('CFG_DOMAIN_'));
const domainKeysWithContent = allDomainKeys.filter(item => {
const value = item.value || '';
return value.trim() !== '';
});
// Only render domains that have content
domainKeysWithContent.forEach(item => {
const value = item.value || '';
const title = item.title || ConfigShared.formatConfigLabel(item.key);
const fieldId = `config-${item.key}`;
// Extract domain number
const domainNum = parseInt(item.key.match(/CFG_DOMAIN_(\d+)/)[1]);
const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k =>
parseInt(k.key.match(/CFG_DOMAIN_(\d+)/)[1])
));
// Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted
const canDelete = isHighestDomain && domainNum !== 1;
html += `
<div class="domain-building-block">
<div class="domain-header">
${ConfigShared.generateField(fieldId, item.key, value, title, '', {
placeholder: 'example.com',
className: 'domain-input',
onchange: 'window.configManager.validateDomainFormat(this, true)',
oninput: 'window.configManager.validateDomainFormat(this, true)',
onblur: 'window.configManager.validateDomainFormat(this, true)'
})}
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
onclick="window.configManager.deleteDomain('${item.key}', this)"
title="${canDelete ? 'Delete domain' : domainNum === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'}"
${!canDelete ? 'disabled' : ''}>
<span class="delete-icon">×</span>
</button>
</div>
</div>
`;
});
// Add "Add Domain" button outside the grid
const isMaxDomains = domainKeysWithContent.length >= 9;
html += `
</div>
<div class="domain-actions">
<button type="button" class="add-domain-btn ${isMaxDomains ? 'disabled' : ''}"
onclick="window.configManager.addDomain(this)"
${isMaxDomains ? 'disabled' : ''}>
<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>
<span class="add-text">${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}</span>
</button>
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
`;
return html;
}
// Helper method to render remote backup section with toggle
renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
const isEnabled = backupKey.value === 'true';
const sectionId = `backup-${backupKey.key}`;
const toggleId = `${backupKey.key.toLowerCase()}-toggle`;
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
</div>
<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="${backupKey.key}"
value="true"
${isEnabled ? 'checked' : ''}
data-toggle-config="${backupKey.key}"
data-section-id="section-content-${sectionId}"
data-toggle-type="checkbox"
onchange="window.toggleManager.toggle('${backupKey.key}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${backupKey.title || 'Enable Remote Backup'}
<span class="tooltip" title="${backupKey.description || 'Enable this remote backup location'}"></span>
</span>
</label>
</div>
</div>
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
<div class="config-fields">
`;
// Add all other fields (excluding the ENABLED toggle)
configItems.filter(item => item.key !== backupKey.key).forEach(item => {
const fieldId = `config-${item.key}`;
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
});
html += `
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
// Helper method to render mail section with toggle
renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
const isEnabled = mailKey.value === 'true';
const sectionId = `mail-${mailKey.key}`;
const toggleId = `${mailKey.key.toLowerCase()}-toggle`;
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
</div>
<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="${mailKey.key}"
value="true"
${isEnabled ? 'checked' : ''}
data-toggle-config="${mailKey.key}"
data-section-id="section-content-${sectionId}"
data-toggle-type="checkbox"
onchange="window.toggleManager.toggle('${mailKey.key}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${mailKey.title || 'Enable Mail Configuration'}
<span class="tooltip" title="${mailKey.description || 'Enable mail server configuration for applications'}"></span>
</span>
</label>
</div>
</div>
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
<div class="config-fields">
`;
// Add all other fields (excluding the ENABLED toggle)
configItems.filter(item => item.key !== mailKey.key).forEach(item => {
const fieldId = `config-${item.key}`;
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
});
// Add test connection button after all mail fields
html += `
<div class="field-group">
<button type="button" class="test-connection-btn" onclick="window.configManager.testMailConnection('${mailKey.key}')">
<span class="test-icon">📧</span>
<span class="test-text">Test Mail Connection</span>
</button>
<div id="mail-test-result" class="test-result" style="display: none;"></div>
</div>
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
// Test mail server connection
async testMailConnection(mailKey) {
const resultDiv = document.getElementById('mail-test-result');
const button = event.target.closest('button');
// Show loading state
button.disabled = true;
button.innerHTML = '<span class="test-icon">⏳</span><span class="test-text">Testing...</span>';
resultDiv.style.display = 'block';
resultDiv.className = 'test-result testing';
resultDiv.innerHTML = 'Testing mail server connection...';
// Initialize mailConfig outside try block for catch block access
let mailConfig = {};
try {
// Get current mail configuration values from the form
mailConfig = {
host: document.querySelector('input[name="CFG_MAIL_HOST"]')?.value || '',
port: document.querySelector('input[name="CFG_MAIL_PORT"]')?.value || '',
secure: document.querySelector('select[name="CFG_MAIL_SECURE"]')?.value || '',
username: document.querySelector('input[name="CFG_MAIL_USERNAME"]')?.value || '',
password: document.querySelector('input[name="CFG_MAIL_PASSWORD"]')?.value || '',
from: document.querySelector('input[name="CFG_MAIL_FROM"]')?.value || ''
};
// Validate required fields
if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) {
throw new Error('Please fill in all required mail server fields (host, port, username, password)');
}
// Call backend test script
const response = await fetch('/api/test-mail-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(mailConfig)
});
const result = await response.json();
if (result.success) {
resultDiv.className = 'test-result success';
resultDiv.innerHTML = `${result.message || 'Mail server connection successful!'}${result.details ? `<br><small>${result.details}</small>` : ''}`;
} else {
resultDiv.className = 'test-result error';
let errorHtml = `${result.message || 'Mail server connection failed'}`;
// Add detailed error information directly underneath
if (result.details || result.error || result.config) {
errorHtml += `
<div style="margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; font-family: monospace; font-size: 11px; white-space: pre-wrap;">
<strong>Error Details:</strong><br>
${result.details || result.error || 'No additional details available'}
${result.stack ? `<br><br><strong>Stack Trace:</strong><br>${result.stack}` : ''}
${result.config ? `<br><br><strong>Connection Config:</strong><br>${JSON.stringify(result.config, null, 2)}` : ''}
</div>
`;
}
resultDiv.innerHTML = errorHtml;
}
} catch (error) {
resultDiv.className = 'test-result error';
resultDiv.innerHTML = `
${error.message || 'Failed to test mail connection'}
<div style="margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; font-family: monospace; font-size: 11px; white-space: pre-wrap;">
<strong>Error Details:</strong><br>
${error.message || 'Unknown error'}
${error.stack ? `<br><br><strong>Stack Trace:</strong><br>${error.stack}` : ''}
${error.response ? `<br><br><strong>Response:</strong><br>${JSON.stringify(error.response, null, 2)}` : ''}
${mailConfig ? `<br><br><strong>Mail Config:</strong><br>${JSON.stringify({...mailConfig, password: mailConfig.password ? '[REDACTED]' : undefined}, null, 2)}` : ''}
</div>
`;
} finally {
// Restore button state
button.disabled = false;
button.innerHTML = '<span class="test-icon">📧</span><span class="test-text">Test Mail Connection</span>';
}
}
// Helper method to render Git section with toggle
renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config = {}) {
// CFG_INSTALL_MODE controls git section: 'git' = enabled, 'local' = disabled
const isEnabled = gitKey.value === 'git';
const sectionId = `git-${gitKey.key}`;
const toggleId = `${gitKey.key.toLowerCase()}-toggle`;
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
</div>
<div class="domains-divider"></div>
<div class="config-fields">
<div class="field-group">
<label for="${toggleId}">
${gitKey.title || 'Install Mode'}
<span class="tooltip" title="${gitKey.description || 'Choose between Git repository or local folder installation'}"></span>
</label>
<select id="${toggleId}"
name="${gitKey.key}"
class="form-control"
data-toggle-config="${gitKey.key}"
data-section-id="section-content-${sectionId}"
data-toggle-type="select"
onchange="window.toggleManager.toggle('${gitKey.key}', this.value === 'git')">
${ConfigOptions.getSelectOptions('CFG_INSTALL_MODE').map(opt => `<option value="${opt.value}" ${opt.value === gitKey.value ? 'selected' : ''}>${opt.label}</option>`).join('')}
</select>
</div>
</div>
<div id="section-content-${sectionId}" class="git-section-content ${isEnabled ? '' : 'hidden'}">
<div class="config-fields">
`;
// Add all other git fields (excluding the CFG_INSTALL_MODE itself)
configItems.filter(item => item.key !== gitKey.key && item.key.startsWith('CFG_GIT_')).forEach(item => {
const fieldId = `config-${item.key}`;
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
});
html += `
</div>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
// Helper method to clean description text by removing tags
cleanDescription(description) {
return description
.replace(/\*\*ADVANCED\*\*/g, '')
.replace(/\*\*UNUSED\*\*/g, '')
.replace(/^\s+|\s+$/g, '') // Trim whitespace
.replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space
}
// Update all domain delete button states
updateDomainDeleteButtons() {
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
// Find the highest domain number (regardless of content)
const domainNumbers = Array.from(allDomainBlocks).map(block => {
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
if (input) {
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
return match ? parseInt(match[1]) : 0;
}
return 0;
}).filter(num => num > 0);
const highestDomain = Math.max(...domainNumbers, 0);
// Update delete buttons
allDomainBlocks.forEach(block => {
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
const deleteBtn = block.querySelector('.delete-domain-btn');
if (input && deleteBtn) {
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
const domainNum = match ? parseInt(match[1]) : 0;
// SIMPLE RULE: Only highest numbered domain can be deleted (except Domain 1)
const canDelete = domainNum === highestDomain && domainNum !== 1;
if (canDelete) {
deleteBtn.classList.remove('disabled');
deleteBtn.disabled = false;
deleteBtn.title = 'Delete domain';
} else {
deleteBtn.classList.add('disabled');
deleteBtn.disabled = true;
if (domainNum === 1) {
deleteBtn.title = 'Domain 1 cannot be deleted';
} else {
deleteBtn.title = 'Can only delete highest numbered domain';
}
}
}
});
}
// Validate domain format when user tries to add a new domain
validateDomainFormat(input, showNotifications = true) {
const value = input.value.trim();
if (!value) {
return true; // Allow empty for initial state
}
// Basic domain validation regex - supports subdomains and multiple TLD levels
const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/;
const isValidFormat = domainRegex.test(value);
// Check for duplicates
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
const duplicates = Array.from(allDomainInputs).filter(otherInput => {
if (otherInput === input) return false; // Skip self
return otherInput.value.trim().toLowerCase() === value.toLowerCase();
});
const hasDuplicate = duplicates.length > 0;
if (!isValidFormat) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid domain format (e.g., example.com)';
if (showNotifications && window.notificationSystem) {
window.notificationSystem.error(`Invalid domain format: "${value}". Please use a valid domain like example.com`);
}
return false;
} else if (hasDuplicate) {
input.style.borderColor = '#ffc107';
input.title = 'Domain already exists!';
if (showNotifications && window.notificationSystem) {
window.notificationSystem.warning(`Domain "${value}" already exists. Please use a unique domain.`);
}
return false;
} else {
input.style.borderColor = '';
input.title = '';
return true;
}
}
// Validate email format for mail fields
validateEmailFormat(input, showNotifications = true) {
const value = input.value.trim();
if (!value) {
return true; // Allow empty for initial state
}
// Email validation regex
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const isValidFormat = emailRegex.test(value);
if (!isValidFormat) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid email format (e.g., user@example.com)';
if (showNotifications && window.notificationSystem) {
window.notificationSystem.error(`Invalid email format: "${value}". Please use a valid email like user@example.com`);
}
return false;
} else {
input.style.borderColor = '';
input.title = '';
return true;
}
}
// Validate hostname format for mail server
validateHostnameFormat(input, showNotifications = true) {
const value = input.value.trim();
if (!value) {
return true; // Allow empty for initial state
}
// Hostname validation regex - allows subdomains and multiple TLD levels
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/;
const isValidFormat = hostnameRegex.test(value);
if (!isValidFormat) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid hostname format (e.g., mail.domain.com)';
if (showNotifications && window.notificationSystem) {
window.notificationSystem.error(`Invalid hostname format: "${value}". Please use a valid hostname like mail.domain.com`);
}
return false;
} else {
input.style.borderColor = '';
input.title = '';
return true;
}
}
// Validate port number for mail server
validatePortNumber(input, showNotifications = true) {
const value = input.value.trim();
if (!value) {
return true; // Allow empty for initial state
}
const port = parseInt(value, 10);
const isValidPort = !isNaN(port) && port >= 1 && port <= 65535;
if (!isValidPort) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid port number (1-65535)';
if (showNotifications && window.notificationSystem) {
window.notificationSystem.error(`Invalid port number: "${value}". Please use a valid port between 1 and 65535`);
}
return false;
} else {
input.style.borderColor = '';
input.title = '';
return true;
}
}
// Check if all domains are valid before allowing new domain addition
canAddNewDomain() {
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
for (const input of allDomainInputs) {
if (!this.validateDomainFormat(input, true)) { // Don't show notifications during bulk check
return false; // At least one domain has invalid format or duplicate
}
}
return true; // All domains are valid
}
// Add a new domain field
addDomain(button) {
// Find all existing domain blocks
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
const domainData = Array.from(allDomainBlocks).map(block => {
const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]');
if (input) {
const match = input.id.match(/CFG_DOMAIN_(\d+)/);
return {
num: match ? parseInt(match[1]) : 0,
value: input.value.trim(),
input: input,
block: block
};
}
return null;
}).filter(item => item !== null);
// Sort by domain number
domainData.sort((a, b) => a.num - b.num);
// Find the highest domain number with content
const domainsWithContent = domainData.filter(d => d.value);
const highestDomainWithContent = domainsWithContent.length > 0 ?
Math.max(...domainsWithContent.map(d => d.num)) : 0;
// Find the highest domain number overall (including empty)
const highestDomainOverall = Math.max(...domainData.map(d => d.num), 0);
// Check if the highest domain (overall) is empty
const highestDomainData = domainData.find(d => d.num === highestDomainOverall);
if (highestDomainData && !highestDomainData.value) {
// Flash the empty highest domain without validation checks
const emptyInput = document.querySelector(`input[id="config-CFG_DOMAIN_${highestDomainOverall}"]`);
if (emptyInput) {
emptyInput.style.animation = 'flash 0.5s ease-in-out 2';
emptyInput.focus();
setTimeout(() => {
emptyInput.style.animation = '';
}, 1000);
return;
}
// Remove animation after it completes
setTimeout(() => {
input.style.animation = '';
}, 1000);
return;
}
// Find the next available domain slot (only if highest with content is filled)
const usedNumbers = domainData.map(d => d.num);
let nextDomain = 1;
while (usedNumbers.includes(nextDomain) && nextDomain <= 9) {
nextDomain++;
}
// Only add if we have domains with content and the highest with content is filled
if (highestDomainWithContent === 0) {
// No domains with content yet, this shouldn't happen but handle it
} else if (nextDomain > 9) {
if (window.notificationSystem) {
window.notificationSystem.warning('Maximum of 9 domains reached!');
}
return;
}
// Before adding new domain, validate that all existing domains have valid format
if (!this.canAddNewDomain()) {
// Find the first invalid domain and focus it with flash
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
for (const input of allDomainInputs) {
if (!this.validateDomainFormat(input, false)) { // Don't show notification here
input.style.animation = 'flash 0.5s ease-in-out 2';
input.focus();
setTimeout(() => {
input.style.animation = '';
}, 1000);
return; // Just flash and focus, no extra notification
}
}
}
// Create new domain field with proper structure
const domainKey = `CFG_DOMAIN_${nextDomain}`;
const fieldId = `config-${domainKey}`;
const title = `Domain ${nextDomain}`;
const newDomainHTML = `
<div class="domain-building-block">
<div class="domain-header">
${ConfigShared.generateField(fieldId, domainKey, '', title, '', {
placeholder: 'example.com',
className: 'domain-input',
onchange: 'window.configManager.validateDomainFormat(this, true)',
oninput: 'window.configManager.validateDomainFormat(this, true)',
onblur: 'window.configManager.validateDomainFormat(this, true)'
})}
<button type="button" class="delete-domain-btn"
onclick="window.configManager.deleteDomain('${domainKey}', this)"
title="Delete domain">
<span class="delete-icon">×</span>
</button>
</div>
</div>
`;
// Insert inside the domain-building-blocks container before the domain-actions
const domainBlocks = button.closest('.domains-wrapper').querySelector('.domain-building-blocks');
domainBlocks.insertAdjacentHTML('beforeend', newDomainHTML);
// Update all delete button states after DOM is ready
setTimeout(() => this.updateDomainDeleteButtons(), 10);
// Update button state if we're now at max domains
const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length;
if (totalDomains >= 9) {
button.classList.add('disabled');
button.disabled = true;
const iconSpan = button.querySelector('.add-icon');
const textSpan = button.querySelector('.add-text');
iconSpan.textContent = '✓';
textSpan.textContent = 'Maximum Domains Reached';
}
}
// Delete a domain field
deleteDomain(domainKey, button) {
const domainBlock = button.closest('.domain-building-block');
// Clear the domain value
const input = document.getElementById(`config-${domainKey}`);
if (input) {
input.value = '';
}
// Remove the domain block if it's empty
if (!input || input.value === '') {
domainBlock.remove();
}
// Update all delete button states after DOM is ready
setTimeout(() => this.updateDomainDeleteButtons(), 10);
// Update add button state
const addButton = document.querySelector('.add-domain-btn');
if (addButton) {
const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length;
const domainsWithContent = Array.from(document.querySelectorAll('[id^="config-CFG_DOMAIN_"]'))
.filter(input => input.value.trim() !== '').length;
if (totalDomains < 9) {
addButton.classList.remove('disabled');
addButton.disabled = false;
const iconSpan = addButton.querySelector('.add-icon');
const textSpan = addButton.querySelector('.add-text');
iconSpan.textContent = '+';
textSpan.textContent = 'Add Domain';
} else {
addButton.classList.add('disabled');
addButton.disabled = true;
const iconSpan = addButton.querySelector('.add-icon');
const textSpan = addButton.querySelector('.add-text');
iconSpan.textContent = '✓';
textSpan.textContent = 'Maximum Domains Reached';
}
}
}
// Helper method to render subcategory with proper sectioning, dividers and headers
renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) {
//console.log(`renderSubcategorySection: subcategory=${subcategoryDescription}, configKeys=${Object.keys(config)}, CFG_INSTALL_MODE=${config.CFG_INSTALL_MODE?.value}`);
const cleanDescription = this.cleanDescription(subcategoryDescription);
let html = `
<div class="config-category">
<h3>${displaySubcategory}</h3>
<p class="category-description">${cleanDescription}</p>
<div class="domains-wrapper">
<div class="domains-divider"></div>
<div class="config-fields">
`;
// Add all config items using standard layout
configItems.forEach((item, index) => {
const fieldId = `config-${item.key}`;
const cleanItemDescription = this.cleanDescription(item.description || '');
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, cleanItemDescription, item.options);
});
html += `
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
// Helper method to render regular subcategory
renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) {
let html = `
<div class="config-category">
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
<div class="config-group">
`;
configItems.forEach(item => {
const fieldId = `config-${item.key}`;
html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config);
});
html += `
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
}
// Global instance
window.configManager = new ConfigManager();