feat(catalog/webui): domains-style block editor for CFG_CATALOG_* sources

Adds the Catalog Sources block to the config editor (General → App Catalogs),
mirroring the domains block UI: add/remove up to 9 catalog URL slots, saved by
the generic config-form (any CFG_* input) → configUpdateBatch, which rebuilds
registry_catalog.json. Official catalog stays source #1 (not editable here);
third-party rows carry an "unverified" note.

- new catalog-manager.js (lean CatalogManager; reuses the domains block CSS,
  URL inputs, no DNS checks) + window.addCatalog/deleteCatalog globals.
- config-manager.js routes the general_catalogs subcategory to it.
- system-loader.js loads catalog-manager.js before config-manager.js.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
librelad 2026-07-06 22:08:29 +01:00
parent cf238ae644
commit 506a3cf7ca
3 changed files with 162 additions and 3 deletions

View File

@ -0,0 +1,152 @@
// Catalog Sources - block editor for CFG_CATALOG_1..9 (extra App Center catalog
// URLs). Mirrors the domains block UI (reuses its CSS) but with URL inputs and
// no DNS checks. Values are saved by the generic config-form (any CFG_* input),
// and configUpdateBatch rebuilds registry_catalog.json when a CFG_CATALOG_*
// changes. The official catalog is source #1 and is not editable here.
class CatalogManager {
_num(key) { const m = key.match(/CFG_CATALOG_(\d+)/); return m ? parseInt(m[1], 10) : 0; }
_esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
_block(key, value, canDelete) {
const fieldId = `config-${key}`;
return `
<div class="domain-building-block">
<div class="domain-header">
<input type="url" id="${fieldId}" name="${key}" value="${this._esc(value)}"
class="form-control catalog-input" placeholder="https://catalog.example.org"
oninput="window.validateCatalogFormat && window.validateCatalogFormat(this)">
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
onclick="window.deleteCatalog('${key}', this)"
title="${canDelete ? 'Remove catalog' : 'Only the last catalog can be removed'}"
${!canDelete ? 'disabled' : ''}>
<span class="delete-icon">×</span>
</button>
</div>
</div>`;
}
async renderCatalogsSection(configItems, displaySubcategory, subcategoryDescription) {
const keys = configItems.map(i => i.key).filter(k => k.startsWith('CFG_CATALOG_'));
const withContent = keys
.filter(k => ((configItems.find(i => i.key === k) || {}).value || '').trim() !== '')
.sort((a, b) => this._num(a) - this._num(b));
const highest = withContent.length ? Math.max(...withContent.map(k => this._num(k))) : 0;
const isMax = withContent.length >= 9;
let blocks = '';
withContent.forEach(key => {
const value = (configItems.find(i => i.key === key) || {}).value || '';
blocks += this._block(key, value, this._num(key) === highest);
});
return `
<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="traefik-warning-banner">
<div class="traefik-warning-content">
<span class="traefik-warning-icon">🛈</span>
<div class="traefik-warning-text">
<strong>The official catalog is always included.</strong> Add third-party catalog URLs to browse more apps third-party sources are <em>unverified</em> (you are trusting that host). Each is served with a signed <code>/&lt;channel&gt;/index.json</code>.
</div>
</div>
</div>
<div class="domain-building-blocks">
${blocks}
</div>
<div class="domain-actions">
<button type="button" id="add-catalog-btn" class="btn ${isMax ? 'btn-secondary' : 'btn-primary'}"
onclick="window.addCatalog()" ${isMax ? 'disabled' : ''}>
<span class="add-icon">${isMax ? '✓' : '+'}</span>${isMax ? 'Maximum Catalogs Reached' : 'Add Catalog'}
</button>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>`;
}
}
// --- standalone block add/remove (mirrors the domain globals) ----------------
function _catalogBlocks() {
return Array.from(document.querySelectorAll('.domain-building-block'))
.filter(b => b.querySelector('input[name^="CFG_CATALOG_"]'));
}
window.addCatalog = function () {
const blocks = _catalogBlocks();
const nums = blocks.map(b => {
const m = b.querySelector('input').name.match(/CFG_CATALOG_(\d+)/); return m ? parseInt(m[1], 10) : 0;
});
if (nums.length >= 9) return;
const next = nums.length ? Math.max(...nums) + 1 : 1;
const key = `CFG_CATALOG_${next}`;
const fieldId = `config-${key}`;
const html = `
<div class="domain-building-block">
<div class="domain-header">
<input type="url" id="${fieldId}" name="${key}" value="" class="form-control catalog-input"
placeholder="https://catalog.example.org"
oninput="window.validateCatalogFormat && window.validateCatalogFormat(this)">
<button type="button" class="delete-domain-btn" onclick="window.deleteCatalog('${key}', this)">
<span class="delete-icon">×</span>
</button>
</div>
</div>`;
const container = document.querySelector('.domain-building-blocks');
if (!container) return;
const tmp = document.createElement('div');
tmp.innerHTML = html;
const block = tmp.firstElementChild;
container.appendChild(block);
const input = block.querySelector('input');
if (input) input.focus();
window.updateCatalogButtons();
};
window.deleteCatalog = function (key, btn) {
const block = btn.closest('.domain-building-block');
if (!block) return;
const input = block.querySelector('input');
if (input) input.value = '';
block.remove();
window.updateCatalogButtons();
};
window.updateCatalogButtons = function () {
const blocks = _catalogBlocks();
const nums = blocks.map(b => {
const m = b.querySelector('input').name.match(/CFG_CATALOG_(\d+)/); return m ? parseInt(m[1], 10) : 0;
});
const highest = nums.length ? Math.max(...nums) : 0;
blocks.forEach(b => {
const del = b.querySelector('.delete-domain-btn');
const n = parseInt((b.querySelector('input').name.match(/CFG_CATALOG_(\d+)/) || [])[1] || '0', 10);
const canDelete = n === highest;
if (del) {
del.disabled = !canDelete;
del.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`;
}
});
const addBtn = document.getElementById('add-catalog-btn');
if (addBtn) {
const isMax = blocks.length >= 9;
addBtn.disabled = isMax;
addBtn.className = `btn ${isMax ? 'btn-secondary' : 'btn-primary'}`;
addBtn.innerHTML = `<span class="add-icon">${isMax ? '✓' : '+'}</span>${isMax ? 'Maximum Catalogs Reached' : 'Add Catalog'}`;
}
};
// Light URL validation — the backend re-validates. Just a red border hint.
window.validateCatalogFormat = function (input) {
const v = (input.value || '').trim();
const ok = v === '' || /^https?:\/\/[^\s/]+/i.test(v);
input.style.borderColor = ok ? '' : '#dc3545';
input.title = ok ? '' : 'Catalog must be an http(s):// URL';
return ok;
};

View File

@ -5,6 +5,7 @@ if (typeof window.ConfigManager === 'undefined') {
constructor() {
this.core = new ConfigCore();
this.domainManager = new DomainManager();
this.catalogManager = new CatalogManager();
this.whitelistManager = new IPWhitelistManager();
this.renderer = new ConfigRenderer();
this.sidebar = new ConfigSidebar();
@ -287,15 +288,20 @@ if (typeof window.ConfigManager === 'undefined') {
// Special handling for domains section
var isDomains = subcategoryName.includes('domains') || subcategoryName.includes('network_domains');
// Special handling for the App Catalogs block (CFG_CATALOG_*)
var isCatalogs = subcategoryName === 'general_catalogs' || subcategoryName.includes('catalogs');
// Special handling for IP whitelist section
var isWhitelist = subcategoryName === 'network_whitelist' || subcategoryName.includes('whitelist');
var resultHTML = '';
if (isDomains) {
// Render domains section with special handling
resultHTML = await this.domainManager.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription);
} else if (isCatalogs) {
resultHTML = await this.catalogManager.renderCatalogsSection(configItems, displaySubcategory, subcategoryDescription);
} else if (isWhitelist) {
resultHTML = await this.whitelistManager.renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription);
} else if (enabledKey) {

View File

@ -43,6 +43,7 @@ class SystemLoader {
'/components/admin/config/js/toggle-manager.js',
'/components/admin/config/js/config-core.js',
'/components/admin/config/js/domain-manager.js',
'/components/admin/config/js/catalog-manager.js',
'/components/admin/config/js/ip-whitelist-manager.js',
'/components/admin/config/js/config-renderer.js',
'/components/admin/config/js/config-sidebar.js',