refactor(apps): decompose apps-manager god-file into 7 responsibility files
Brace-aware split of apps-manager.js (4154->2015 base) into apps-grid,
config-form, port-manager-integration, gluetun-vpn, config-dirty-guard,
install-console, service-buttons-sidebar — augmenting AppsManager.prototype.
Removed the dead duplicate initializeSimpleTabs (kept the live later def).
Used a self-checking extractor (node --check per cluster, auto-revert on
failure): install-dispatch contains a regex literal (/^\d{16}$/) that trips
brace-bounding, so its methods were safely LEFT in the base rather than risk a
bad split. ServiceButtons class + expandServiceLinks + bootstrap also remain
inline. Verified: all 117 methods preserved (none lost), all files
node --check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
3d058b3469
commit
df75059330
@ -0,0 +1,280 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
setupSidebar(activeCategory = 'all') {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
// Clear sidebar
|
||||||
|
const container = document.getElementById('dynamic-categories');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Hide loading
|
||||||
|
const loading = document.querySelector('.loading-categories');
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
|
||||||
|
// Add categories
|
||||||
|
this.addCategory('All', 'all');
|
||||||
|
this.addCategory('Installed', 'installed');
|
||||||
|
|
||||||
|
// Add dynamic categories
|
||||||
|
if (window.sidebarCategories) {
|
||||||
|
const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories :
|
||||||
|
Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value }));
|
||||||
|
|
||||||
|
categoriesArray.forEach(cat => {
|
||||||
|
this.addCategory(cat.name, cat.id, cat.icon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add back button for app detail
|
||||||
|
if (this.currentView === 'app-detail') {
|
||||||
|
this.addBackButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set active category
|
||||||
|
this.setActiveCategory(activeCategory);
|
||||||
|
},
|
||||||
|
addCategory(name, id, icon) {
|
||||||
|
const container = document.getElementById('dynamic-categories');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'category';
|
||||||
|
div.setAttribute('data-category', id);
|
||||||
|
|
||||||
|
let iconHtml;
|
||||||
|
if (!icon && id === 'all') {
|
||||||
|
iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
|
||||||
|
} else if (!icon && id === 'installed') {
|
||||||
|
iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
} else {
|
||||||
|
let iconPath = icon || `/core/icons/categories/${id}.svg`;
|
||||||
|
if (!iconPath.startsWith('/')) iconPath = '/' + iconPath;
|
||||||
|
iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/core/icons/categories/default.svg'"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = `${iconHtml} ${name}`;
|
||||||
|
|
||||||
|
div.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.switchCategory(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
},
|
||||||
|
addBackButton() {
|
||||||
|
const container = document.getElementById('dynamic-categories');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'category';
|
||||||
|
div.innerHTML = '← Back to Apps';
|
||||||
|
div.addEventListener('click', () => {
|
||||||
|
this.showAppsList('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
},
|
||||||
|
setActiveCategory(categoryId) {
|
||||||
|
document.querySelectorAll('.category').forEach(cat => {
|
||||||
|
cat.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = document.querySelector(`[data-category="${categoryId}"]`);
|
||||||
|
if (active) active.classList.add('active');
|
||||||
|
},
|
||||||
|
switchCategory(categoryId) {
|
||||||
|
//// // console.log(`AppsManager: Switching to category: ${categoryId}`);
|
||||||
|
|
||||||
|
// Update URL to reflect current state
|
||||||
|
if (categoryId === 'all') {
|
||||||
|
history.pushState({}, '', '/apps');
|
||||||
|
} else {
|
||||||
|
history.pushState({}, '', `/apps/${categoryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct view update without URL change to avoid conflicts
|
||||||
|
this.currentView = 'apps';
|
||||||
|
this.currentApp = null;
|
||||||
|
|
||||||
|
// Switch to apps view
|
||||||
|
this.showView('apps');
|
||||||
|
|
||||||
|
// Render apps for new category
|
||||||
|
this.renderApps(categoryId);
|
||||||
|
|
||||||
|
// Update sidebar for new category
|
||||||
|
this.setupSidebar(categoryId);
|
||||||
|
},
|
||||||
|
renderApps(category) {
|
||||||
|
const container = document.getElementById('apps-section');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Load and render apps
|
||||||
|
this.loadApps(category).then(apps => {
|
||||||
|
apps.forEach(app => {
|
||||||
|
const card = this.createAppCard(app);
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
this.populateInlineServiceButtons();
|
||||||
|
// Re-apply any active sidebar search so changing category
|
||||||
|
// doesn't reveal apps that should be filtered out.
|
||||||
|
if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error rendering apps:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Client-side substring filter wired to the sidebar search box.
|
||||||
|
// Cards carry data-search (built in createAppCard) so this stays
|
||||||
|
// a single querySelectorAll + display toggle.
|
||||||
|
filterAppsByQuery(query) {
|
||||||
|
const q = (query || '').trim().toLowerCase();
|
||||||
|
this.appsSearchQuery = q;
|
||||||
|
const wrap = document.querySelector('.apps-search');
|
||||||
|
if (wrap) wrap.classList.toggle('has-value', !!q);
|
||||||
|
|
||||||
|
const cards = document.querySelectorAll('#apps-section .app-card');
|
||||||
|
cards.forEach(card => {
|
||||||
|
const hay = card.dataset.search || '';
|
||||||
|
card.style.display = (!q || hay.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearAppsSearch() {
|
||||||
|
const input = document.getElementById('apps-search-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
this.filterAppsByQuery('');
|
||||||
|
if (input) input.focus();
|
||||||
|
},
|
||||||
|
createAppCard(app) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'app-card';
|
||||||
|
if (app.installed) card.classList.add('installed');
|
||||||
|
|
||||||
|
// Searchable text for the sidebar search box. Combined name +
|
||||||
|
// description + long description + category, lowercased once here
|
||||||
|
// so filterAppsByQuery is a cheap substring match.
|
||||||
|
const searchHaystack = [
|
||||||
|
app.name,
|
||||||
|
app.description,
|
||||||
|
app.longDescription,
|
||||||
|
app.category,
|
||||||
|
this.getCategoryName ? this.getCategoryName(app.category) : ''
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
card.dataset.search = searchHaystack;
|
||||||
|
|
||||||
|
const appName = app.command.split(' ').pop();
|
||||||
|
let icon = app.icon || '/core/icons/apps/default.svg';
|
||||||
|
|
||||||
|
// Ensure absolute path from root
|
||||||
|
if (!icon.startsWith('/')) {
|
||||||
|
icon = '/' + icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = app.installed ? 'Installed' : 'Not Installed';
|
||||||
|
|
||||||
|
// Get category icon and name
|
||||||
|
const categoryIcon = this.getCategoryIcon(app.category);
|
||||||
|
const categoryName = this.getCategoryName(app.category);
|
||||||
|
|
||||||
|
// Create rich tags like original
|
||||||
|
const descriptionTag = app.description ? `<span class="app-tag description-tag"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg> ${app.description}</span>` : '';
|
||||||
|
const categoryTag = `<span class="app-tag category-tag clickable" onclick="event.stopPropagation(); appsManager.switchCategory('${app.category}')"><img src="${categoryIcon}"/> ${categoryName}</span>`;
|
||||||
|
|
||||||
|
// Format long description with period if missing
|
||||||
|
let formattedLongDescription = '';
|
||||||
|
if (app.longDescription) {
|
||||||
|
formattedLongDescription = app.longDescription;
|
||||||
|
if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) {
|
||||||
|
formattedLongDescription += '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service trigger icon (only for installed apps - visibility controlled after services load)
|
||||||
|
const serviceTrigger = app.installed ? `
|
||||||
|
<div class="service-trigger" id="service-trigger-${appName}" style="display:none;">
|
||||||
|
<div class="service-trigger-icon" onclick="event.stopPropagation(); window.toggleServiceTrigger('${appName}')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Open
|
||||||
|
</div>
|
||||||
|
<div class="service-trigger-popup">
|
||||||
|
<div id="service-popup-content-${appName}"></div>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
|
||||||
|
<div class="app-card-icon">
|
||||||
|
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
|
||||||
|
</div>
|
||||||
|
<div class="app-card-content">
|
||||||
|
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
|
||||||
|
<div class="app-card-tags">
|
||||||
|
${descriptionTag}
|
||||||
|
${categoryTag}
|
||||||
|
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
|
||||||
|
<div class="app-card-actions">
|
||||||
|
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
|
||||||
|
${app.installed ? 'Manage' : 'Install'}
|
||||||
|
</button>
|
||||||
|
${serviceTrigger}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
},
|
||||||
|
// Populate inline service trigger popups for installed apps
|
||||||
|
async populateInlineServiceButtons() {
|
||||||
|
if (!window.serviceButtons) return;
|
||||||
|
|
||||||
|
if (window.serviceButtons.services.length === 0) {
|
||||||
|
await window.serviceButtons.loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http';
|
||||||
|
|
||||||
|
const popupContents = document.querySelectorAll('[id^="service-popup-content-"]');
|
||||||
|
for (const content of popupContents) {
|
||||||
|
const appName = content.id.replace('service-popup-content-', '');
|
||||||
|
const trigger = document.getElementById(`service-trigger-${appName}`);
|
||||||
|
|
||||||
|
const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true);
|
||||||
|
if (appServices.length === 0) continue;
|
||||||
|
|
||||||
|
// Multi-button render via the shared expandServiceLinks() helper.
|
||||||
|
const buttons = appServices.flatMap(s => {
|
||||||
|
const protectedClass = s.loginRequired ? ' protected' : '';
|
||||||
|
const lockIcon = s.loginRequired
|
||||||
|
? `<span class="service-lock-icon" title="Login required for this URL — credentials in Config → General → Logins."><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>`
|
||||||
|
: '';
|
||||||
|
return window.expandServiceLinks(s).map(({ url, label }) => `
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="service-button${protectedClass}" onclick="event.stopPropagation()">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
${label}
|
||||||
|
${lockIcon}
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
|
if (buttons) {
|
||||||
|
content.innerHTML = buttons;
|
||||||
|
if (trigger) trigger.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,198 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
// Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in
|
||||||
|
// collectConfigFromForm so the two agree on what counts as a config field.
|
||||||
|
_readConfigFieldState(form) {
|
||||||
|
const state = {};
|
||||||
|
form.querySelectorAll('input, select, textarea').forEach((input) => {
|
||||||
|
const name = input.name;
|
||||||
|
if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
|
||||||
|
state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
_getDirtyConfigFields() {
|
||||||
|
if (!this._dirtyAppName || !this._configSnapshot) return [];
|
||||||
|
const form = document.getElementById(`app-form-${this._dirtyAppName}`);
|
||||||
|
if (!form) return [];
|
||||||
|
const current = this._readConfigFieldState(form);
|
||||||
|
return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? ''));
|
||||||
|
},
|
||||||
|
_isConfigDirty() {
|
||||||
|
return this._getDirtyConfigFields().length > 0;
|
||||||
|
},
|
||||||
|
// Called once per config-panel render: snapshots the saved state, wires the
|
||||||
|
// change listener + sticky bar, and (re)registers the nav guard. Only tracks
|
||||||
|
// installed apps — for a fresh install the Install button is already the
|
||||||
|
// "apply" action, so a dirty bar would just be noise.
|
||||||
|
wireConfigDirtyTracking(appName) {
|
||||||
|
const form = document.getElementById(`app-form-${appName}`);
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const app = (window.apps || []).find((a) =>
|
||||||
|
(a.command || '').endsWith(` ${appName}`) ||
|
||||||
|
(a.name && a.name.toLowerCase() === appName.toLowerCase())
|
||||||
|
);
|
||||||
|
if (!app || !app.installed) {
|
||||||
|
this._clearConfigDirty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dirtyAppName = appName;
|
||||||
|
this._configSnapshot = this._readConfigFieldState(form);
|
||||||
|
|
||||||
|
if (form.dataset.dirtyWired !== '1') {
|
||||||
|
form.dataset.dirtyWired = '1';
|
||||||
|
const onEdit = () => this._refreshDirtyBar();
|
||||||
|
form.addEventListener('input', onEdit);
|
||||||
|
form.addEventListener('change', onEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ensureDirtyBar(appName, form);
|
||||||
|
|
||||||
|
// beforeunload covers tab close / refresh / external nav — the browser
|
||||||
|
// shows its own generic prompt. Registered once for the page lifetime.
|
||||||
|
if (!this._beforeUnloadWired) {
|
||||||
|
this._beforeUnloadWired = true;
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA route changes route through this guard (see spa.js navigate()).
|
||||||
|
window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath);
|
||||||
|
|
||||||
|
this._refreshDirtyBar();
|
||||||
|
},
|
||||||
|
// Build (or rebuild) the sticky bar at the bottom of the config form.
|
||||||
|
_ensureDirtyBar(appName, form) {
|
||||||
|
const stale = document.getElementById('config-dirty-bar');
|
||||||
|
if (stale) stale.remove();
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'config-dirty-bar';
|
||||||
|
bar.className = 'config-dirty-bar';
|
||||||
|
bar.style.display = 'none';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<span class="config-dirty-msg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="config-dirty-count"></span>
|
||||||
|
</span>
|
||||||
|
<span class="config-dirty-actions">
|
||||||
|
<button type="button" class="btn btn-secondary config-dirty-discard">Discard</button>
|
||||||
|
<button type="button" class="btn btn-primary config-dirty-apply">Apply</button>
|
||||||
|
</span>`;
|
||||||
|
// Sits in normal flow between the config content and the action buttons.
|
||||||
|
const actions = form.querySelector('.config-actions');
|
||||||
|
if (actions) {
|
||||||
|
form.insertBefore(bar, actions);
|
||||||
|
} else {
|
||||||
|
form.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges());
|
||||||
|
bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName));
|
||||||
|
},
|
||||||
|
_refreshDirtyBar() {
|
||||||
|
const bar = document.getElementById('config-dirty-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
const count = this._getDirtyConfigFields().length;
|
||||||
|
if (count === 0) {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = bar.querySelector('.config-dirty-count');
|
||||||
|
if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`;
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
},
|
||||||
|
// Revert every field to its snapshot value, then re-fire change/input so
|
||||||
|
// dependent UI (showWhen visibility, etc.) reconciles.
|
||||||
|
_discardConfigChanges() {
|
||||||
|
if (!this._dirtyAppName || !this._configSnapshot) return;
|
||||||
|
const form = document.getElementById(`app-form-${this._dirtyAppName}`);
|
||||||
|
if (!form) return;
|
||||||
|
form.querySelectorAll('input, select, textarea').forEach((input) => {
|
||||||
|
const name = input.name;
|
||||||
|
if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
|
||||||
|
if (!(name in this._configSnapshot)) return;
|
||||||
|
const orig = this._configSnapshot[name];
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = (orig === 'true');
|
||||||
|
} else {
|
||||||
|
input.value = orig;
|
||||||
|
}
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
this._refreshDirtyBar();
|
||||||
|
},
|
||||||
|
// Drop the dirty state without touching the form — used when the changes are
|
||||||
|
// being applied (the form is about to be replaced) or discarded on leave.
|
||||||
|
_clearConfigDirty() {
|
||||||
|
this._configSnapshot = null;
|
||||||
|
this._dirtyAppName = null;
|
||||||
|
window.__appConfigNavGuard = null;
|
||||||
|
const bar = document.getElementById('config-dirty-bar');
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
},
|
||||||
|
// SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the
|
||||||
|
// normal apply flow and stays put (apply navigates to the tasks view itself).
|
||||||
|
async _appConfigNavGuard() {
|
||||||
|
if (!this._isConfigDirty()) return 'proceed';
|
||||||
|
const appName = this._dirtyAppName;
|
||||||
|
const decision = await this._confirmLeaveUnsaved(appName);
|
||||||
|
if (decision === 'apply') {
|
||||||
|
this.installApp(appName);
|
||||||
|
return 'stay';
|
||||||
|
}
|
||||||
|
if (decision === 'discard') {
|
||||||
|
this._clearConfigDirty();
|
||||||
|
return 'proceed';
|
||||||
|
}
|
||||||
|
return 'stay';
|
||||||
|
},
|
||||||
|
// Apply / Discard / Stay prompt. Resolves with the chosen action; closing
|
||||||
|
// via the X or backdrop resolves 'stay' (the safe default).
|
||||||
|
_confirmLeaveUnsaved(appName) {
|
||||||
|
let displayName = appName;
|
||||||
|
const app = (window.apps || []).find((a) =>
|
||||||
|
(a.command || '').endsWith(` ${appName}`) ||
|
||||||
|
(a.name && a.name.toLowerCase() === appName.toLowerCase())
|
||||||
|
);
|
||||||
|
if (app && app.name) displayName = app.name.split(' - ')[0].trim();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let decided = false;
|
||||||
|
const finish = (val, modal) => {
|
||||||
|
if (decided) return;
|
||||||
|
decided = true;
|
||||||
|
if (modal) modal.close();
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
window.openEoModal({
|
||||||
|
id: 'config-unsaved-modal',
|
||||||
|
size: 'sm',
|
||||||
|
eyebrow: '⚠ Unsaved changes',
|
||||||
|
title: displayName,
|
||||||
|
desc: 'You have configuration changes that haven’t been applied.',
|
||||||
|
body: `
|
||||||
|
<div class="eo-empty-state warning" role="status">
|
||||||
|
<div class="eo-empty-state-body">
|
||||||
|
<p class="eo-empty-state-title">Apply before you go?</p>
|
||||||
|
<p class="eo-empty-state-text">Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.</p>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) },
|
||||||
|
{ label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) },
|
||||||
|
{ label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) }
|
||||||
|
],
|
||||||
|
onClose: () => { if (!decided) { decided = true; resolve('stay'); } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,350 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
countryFlagEmoji(name) {
|
||||||
|
const map = {
|
||||||
|
'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ',
|
||||||
|
'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO',
|
||||||
|
'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL',
|
||||||
|
'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ',
|
||||||
|
'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET',
|
||||||
|
'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT',
|
||||||
|
'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ',
|
||||||
|
'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ',
|
||||||
|
'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT',
|
||||||
|
'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT',
|
||||||
|
'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP',
|
||||||
|
'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA',
|
||||||
|
'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA',
|
||||||
|
'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG',
|
||||||
|
'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK',
|
||||||
|
'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN',
|
||||||
|
'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB',
|
||||||
|
'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN'
|
||||||
|
};
|
||||||
|
const code = map[name];
|
||||||
|
if (!code) return '🏳️';
|
||||||
|
return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65)));
|
||||||
|
},
|
||||||
|
openGluetunCountriesModal(fieldId) {
|
||||||
|
const hidden = document.getElementById(fieldId);
|
||||||
|
const chips = document.getElementById(`${fieldId}-chips`);
|
||||||
|
if (!hidden) return;
|
||||||
|
|
||||||
|
const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl)
|
||||||
|
? ConfigOptions.findGluetunProviderEl()
|
||||||
|
: null;
|
||||||
|
const provider = providerEl ? providerEl.value : '';
|
||||||
|
const providers = window.gluetunProviders || {};
|
||||||
|
const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries))
|
||||||
|
? [...providers[provider].countries].sort((a, b) => a.localeCompare(b))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean));
|
||||||
|
|
||||||
|
const existing = document.getElementById('gluetun-countries-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const flag = (n) => this.countryFlagEmoji(n);
|
||||||
|
const renderChips = (list) => list.length
|
||||||
|
? list.map(c => `<span class="gluetun-country-chip"><span class="gluetun-flag">${flag(c)}</span>${c}</span>`).join('')
|
||||||
|
: `<span class="gluetun-country-empty">Any</span>`;
|
||||||
|
|
||||||
|
const fallbackProviderIcon = `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2338bdf8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z'/><path d='M9 12l2 2 4-4'/></svg>`;
|
||||||
|
const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —';
|
||||||
|
const iconManifest = window.gluetunProviderIcons || {};
|
||||||
|
const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon;
|
||||||
|
|
||||||
|
const bodyHtml = `
|
||||||
|
<div class="gluetun-provider-card">
|
||||||
|
<div class="gluetun-provider-icon-wrap">
|
||||||
|
<img class="gluetun-provider-icon" src="${providerIconUrl}" alt="${provider}" onerror="this.onerror=null; this.src='${fallbackProviderIcon}';">
|
||||||
|
</div>
|
||||||
|
<div class="gluetun-provider-text">
|
||||||
|
<p class="gluetun-provider-label">Provider</p>
|
||||||
|
<p class="gluetun-provider-name">${providerLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gluetun-search-card">
|
||||||
|
<div class="gluetun-search-row">
|
||||||
|
<svg class="gluetun-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="gluetun-country-search" placeholder="Filter countries...">
|
||||||
|
</div>
|
||||||
|
<div class="gluetun-search-actions">
|
||||||
|
<button type="button" class="btn btn-secondary gluetun-country-all">Select all</button>
|
||||||
|
<button type="button" class="btn btn-secondary gluetun-country-none">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gluetun-country-list">
|
||||||
|
${countries.length === 0
|
||||||
|
? `<p class="gluetun-country-empty-msg">No country list available for this provider. Pick a provider first or wait for the snapshot to load.</p>`
|
||||||
|
: countries.map(c => `
|
||||||
|
<label class="gluetun-country-item">
|
||||||
|
<input type="checkbox" value="${c}" ${current.has(c) ? 'checked' : ''}>
|
||||||
|
<span class="gluetun-country-name"><span class="gluetun-flag">${flag(c)}</span>${c}</span>
|
||||||
|
</label>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const m = window.openEoModal({
|
||||||
|
id: 'gluetun-countries-modal',
|
||||||
|
title: '🌍 Select VPN Countries',
|
||||||
|
body: bodyHtml,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Save', variant: 'primary', onClick: (modal) => {
|
||||||
|
const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value);
|
||||||
|
hidden.value = picked.join(',');
|
||||||
|
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
if (chips) chips.innerHTML = renderChips(picked);
|
||||||
|
modal.close();
|
||||||
|
}},
|
||||||
|
{ label: 'Cancel', variant: 'secondary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const root = m.contentEl;
|
||||||
|
root.querySelector('.gluetun-country-search').addEventListener('input', (e) => {
|
||||||
|
const q = e.target.value.toLowerCase();
|
||||||
|
root.querySelectorAll('.gluetun-country-item').forEach(item => {
|
||||||
|
const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase();
|
||||||
|
item.style.display = label.includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.querySelector('.gluetun-country-all').addEventListener('click', () => {
|
||||||
|
root.querySelectorAll('.gluetun-country-item').forEach(item => {
|
||||||
|
if (item.style.display !== 'none') item.querySelector('input').checked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.querySelector('.gluetun-country-none').addEventListener('click', () => {
|
||||||
|
root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async openGluetunRouteAppsModal() {
|
||||||
|
const existing = document.getElementById('gluetun-route-apps-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
let allow = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
|
||||||
|
if (r.ok) allow = (await r.json()).categories || [];
|
||||||
|
} catch {}
|
||||||
|
const allowSet = new Set(allow.map((c) => String(c).toLowerCase()));
|
||||||
|
const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL');
|
||||||
|
const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']);
|
||||||
|
const apps = (window.apps || [])
|
||||||
|
.filter((a) => a.installed)
|
||||||
|
.map((a) => {
|
||||||
|
const slug = (a.command || '').split(' ').pop();
|
||||||
|
return { ...a, slug };
|
||||||
|
})
|
||||||
|
.filter((a) => a.slug && !skip.has(a.slug))
|
||||||
|
.filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase()))
|
||||||
|
.sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug));
|
||||||
|
|
||||||
|
const bodyHtml = `
|
||||||
|
<p class="eo-modal-section-text">
|
||||||
|
Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network.
|
||||||
|
Each change re-runs that app's install task to apply the new compose.
|
||||||
|
</p>
|
||||||
|
${apps.length === 0 ? `
|
||||||
|
<div class="eo-empty-state info" role="status">
|
||||||
|
<div class="eo-empty-state-icon" aria-hidden="true">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="eo-empty-state-body">
|
||||||
|
<p class="eo-empty-state-title">No eligible installed apps</p>
|
||||||
|
<p class="eo-empty-state-text">
|
||||||
|
Install an app from the curated categories first, or enable the
|
||||||
|
<strong>Gluetun For All Apps</strong> requirement to expose every app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="gluetun-country-list">
|
||||||
|
${apps.map((a) => {
|
||||||
|
const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`;
|
||||||
|
const current = (a.config && a.config[cfgKey]) || 'default';
|
||||||
|
const checked = current === 'gluetun' ? 'checked' : '';
|
||||||
|
const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/core/icons/apps/default.svg';
|
||||||
|
return `
|
||||||
|
<label class="gluetun-country-item">
|
||||||
|
<input type="checkbox" data-slug="${a.slug}" data-current="${current}" ${checked}>
|
||||||
|
<span class="gluetun-country-name" style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<img src="${icon}" alt="" style="width:20px; height:20px; object-fit:contain;" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg';">
|
||||||
|
${a.name || a.slug}
|
||||||
|
</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`}`;
|
||||||
|
|
||||||
|
const m = window.openEoModal({
|
||||||
|
id: 'gluetun-route-apps-modal',
|
||||||
|
title: '🛡️ Route apps through Gluetun',
|
||||||
|
body: bodyHtml,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Apply', variant: 'primary', onClick: async (modal) => {
|
||||||
|
if (apps.length === 0) { modal.close(); return; }
|
||||||
|
const root = modal.contentEl;
|
||||||
|
const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
|
||||||
|
const changes = [];
|
||||||
|
root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => {
|
||||||
|
const desired = cb.checked ? 'gluetun' : 'default';
|
||||||
|
if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired });
|
||||||
|
});
|
||||||
|
if (changes.length === 0) { modal.close(); return; }
|
||||||
|
applyBtn.disabled = true; applyBtn.textContent = 'Applying…';
|
||||||
|
try {
|
||||||
|
if (!window.tasksManager?.router) await this.loadTaskSystem?.();
|
||||||
|
for (const { slug, value } of changes) {
|
||||||
|
const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`;
|
||||||
|
await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } });
|
||||||
|
}
|
||||||
|
this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`);
|
||||||
|
modal.close();
|
||||||
|
if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks');
|
||||||
|
} catch (err) {
|
||||||
|
applyBtn.disabled = false; applyBtn.textContent = 'Apply';
|
||||||
|
console.error('Failed to queue gluetun routing tasks', err);
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{ label: 'Cancel', variant: 'secondary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (apps.length === 0) {
|
||||||
|
const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0];
|
||||||
|
if (applyBtn) applyBtn.disabled = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openMullvadGenerateModal() {
|
||||||
|
const existing = document.getElementById('mullvad-generate-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/components/apps/core/icons/vpn/mullvad.svg';
|
||||||
|
const bodyHtml = `
|
||||||
|
<p class="eo-modal-section-text">
|
||||||
|
Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally
|
||||||
|
and registered with Mullvad — this consumes one of your 5 device slots.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mullvad-acct" style="display:block; margin-bottom:6px; font-size:13px;">Account number</label>
|
||||||
|
<input type="text" id="mullvad-acct" class="form-input" placeholder="1234567890123456" autocomplete="off" inputmode="numeric">
|
||||||
|
</div>
|
||||||
|
<p class="mullvad-error" style="color: #f87171; font-size: 13px; margin: 8px 0 0 0; display:none;"></p>`;
|
||||||
|
|
||||||
|
const m = window.openEoModal({
|
||||||
|
id: 'mullvad-generate-modal',
|
||||||
|
size: 'sm',
|
||||||
|
icon: mullvadIcon,
|
||||||
|
iconAlt: 'Mullvad',
|
||||||
|
eyebrow: 'Provider',
|
||||||
|
title: 'Generate Mullvad Config',
|
||||||
|
body: bodyHtml,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Generate', variant: 'primary', onClick: async (modal) => {
|
||||||
|
const root = modal.contentEl;
|
||||||
|
const errEl = root.querySelector('.mullvad-error');
|
||||||
|
const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
|
||||||
|
const acctEl = root.querySelector('#mullvad-acct');
|
||||||
|
const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; };
|
||||||
|
const account = (acctEl.value || '').replace(/\s+/g, '');
|
||||||
|
if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; }
|
||||||
|
setError('');
|
||||||
|
confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gluetun/mullvad-wireguard', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accountNumber: account })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) {
|
||||||
|
setError(data.error || `Request failed (${res.status}).`);
|
||||||
|
confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null;
|
||||||
|
const setField = (suffix, value) => {
|
||||||
|
const el = findField(suffix); if (!el) return;
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
setField('WIREGUARD_PRIVATE_KEY', data.privateKey);
|
||||||
|
setField('WIREGUARD_ADDRESSES', data.addresses);
|
||||||
|
modal.close();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Network error.');
|
||||||
|
confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{ label: 'Cancel', variant: 'secondary' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
m.contentEl.querySelector('#mullvad-acct').focus();
|
||||||
|
},
|
||||||
|
async shouldRecommendGluetun(appName, appData) {
|
||||||
|
if (appName === 'gluetun') return false;
|
||||||
|
if (this.checkServiceInstalled('gluetun')) return false;
|
||||||
|
const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL');
|
||||||
|
if (overrideOn) return true;
|
||||||
|
if (!this._gluetunEligiblePromise) {
|
||||||
|
this._gluetunEligiblePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return new Set();
|
||||||
|
const j = await r.json();
|
||||||
|
return new Set((j.categories || []).map((c) => String(c).toLowerCase()));
|
||||||
|
} catch { return new Set(); }
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
const allow = await this._gluetunEligiblePromise;
|
||||||
|
const cat = String(appData?.category || '').toLowerCase();
|
||||||
|
return allow.has(cat);
|
||||||
|
},
|
||||||
|
showGluetunRecommendModal(appName, appData) {
|
||||||
|
const existing = document.getElementById('gluetun-recommend-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const displayName = appData?.name?.split(' - ')[0]?.trim() || appName;
|
||||||
|
let icon = appData?.icon || `/core/icons/apps/${appName}.svg`;
|
||||||
|
if (icon && !icon.startsWith('/')) icon = '/' + icon;
|
||||||
|
|
||||||
|
const bodyHtml = `
|
||||||
|
<div class="eo-empty-state warning" role="status">
|
||||||
|
<div class="eo-empty-state-icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="eo-empty-state-body">
|
||||||
|
<p class="eo-empty-state-title">Apps in this category usually benefit from VPN routing</p>
|
||||||
|
<p class="eo-empty-state-text">Without Gluetun, this app's outbound traffic uses your real public IP — exposing activity to ISPs, copyright trackers, and the destination services.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
window.openEoModal({
|
||||||
|
id: 'gluetun-recommend-modal',
|
||||||
|
size: 'sm',
|
||||||
|
icon,
|
||||||
|
iconAlt: displayName,
|
||||||
|
eyebrow: 'About to install',
|
||||||
|
title: displayName,
|
||||||
|
desc: 'VPN routing is recommended for this app.',
|
||||||
|
body: bodyHtml,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => {
|
||||||
|
modal.close();
|
||||||
|
this.showAppDetailWithConfig('gluetun');
|
||||||
|
}},
|
||||||
|
{ label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => {
|
||||||
|
modal.close();
|
||||||
|
this.executeInstall(appName, false);
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
// Terminal logging functions with old styling
|
||||||
|
addLogMessage(message, type = 'info') {
|
||||||
|
const messageLog = document.getElementById('message-log');
|
||||||
|
if (!messageLog) return;
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const messageLine = document.createElement('div');
|
||||||
|
messageLine.className = `log-entry ${type}`;
|
||||||
|
messageLine.innerHTML = `<span class="log-timestamp">[${timestamp}]</span> ${message}`;
|
||||||
|
|
||||||
|
messageLog.appendChild(messageLine);
|
||||||
|
// Only scroll to bottom if user hasn't scrolled up
|
||||||
|
if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) {
|
||||||
|
messageLog.scrollTop = messageLog.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addSuccessLog(message) {
|
||||||
|
this.addLogMessage(message, 'success');
|
||||||
|
},
|
||||||
|
addErrorLog(message) {
|
||||||
|
this.addLogMessage(message, 'error');
|
||||||
|
},
|
||||||
|
addWarningLog(message) {
|
||||||
|
this.addLogMessage(message, 'warning');
|
||||||
|
},
|
||||||
|
addInfoLog(message) {
|
||||||
|
this.addLogMessage(message, 'info');
|
||||||
|
},
|
||||||
|
clearConsole() {
|
||||||
|
const messageLog = document.getElementById('message-log');
|
||||||
|
if (!messageLog) return;
|
||||||
|
|
||||||
|
messageLog.innerHTML = '<div class="log-entry info"><span class="log-timestamp">[' + new Date().toLocaleTimeString() + ']</span> Console cleared...</div>';
|
||||||
|
messageLog.scrollTop = 0; // Force to top
|
||||||
|
},
|
||||||
|
initializeConsole() {
|
||||||
|
const messageLog = document.getElementById('message-log');
|
||||||
|
if (messageLog) {
|
||||||
|
messageLog.scrollTop = 0; // Force scroll to top
|
||||||
|
messageLog.innerHTML = ''; // Clear any existing content
|
||||||
|
// Add initial message after a tiny delay to ensure it renders at top
|
||||||
|
setTimeout(() => {
|
||||||
|
this.addInfoLog('Application configuration loaded successfully');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
// Initialize port managers after DOM is ready
|
||||||
|
async initializePortManagers() {
|
||||||
|
//// // console.log('🔌 Looking for port manager containers...');
|
||||||
|
const portContainers = document.querySelectorAll('.port-manager-container');
|
||||||
|
//// // console.log(`🔌 Found ${portContainers.length} port manager containers`);
|
||||||
|
|
||||||
|
// Group port containers by app
|
||||||
|
const appPortContainers = {};
|
||||||
|
for (const container of portContainers) {
|
||||||
|
const appName = container.dataset.appName;
|
||||||
|
if (!appPortContainers[appName]) {
|
||||||
|
appPortContainers[appName] = [];
|
||||||
|
}
|
||||||
|
appPortContainers[appName].push(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create one consolidated port manager per app
|
||||||
|
for (const [appName, containers] of Object.entries(appPortContainers)) {
|
||||||
|
//// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all port configurations for this app
|
||||||
|
const appConfig = this.getCurrentAppConfig();
|
||||||
|
const allPortConfigs = this.getAllPortConfigs(appConfig, appName);
|
||||||
|
|
||||||
|
// Create consolidated port manager
|
||||||
|
const portManager = new PortManager();
|
||||||
|
const html = portManager.generateHTML(appName, allPortConfigs);
|
||||||
|
|
||||||
|
// Replace the first container with the consolidated port manager
|
||||||
|
const firstContainer = containers[0];
|
||||||
|
firstContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// Hide other port containers (PORT_2, PORT_3, etc.) and their labels
|
||||||
|
for (let i = 1; i < containers.length; i++) {
|
||||||
|
const container = containers[i];
|
||||||
|
const formField = container.closest('.form-field');
|
||||||
|
if (formField) {
|
||||||
|
formField.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
container.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide labels and help text for the first port container
|
||||||
|
this.hidePortFieldLabels(containers[0]);
|
||||||
|
|
||||||
|
// Initialize port manager with services
|
||||||
|
await portManager.initialize(appName);
|
||||||
|
//// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error initializing consolidated port manager for ${appName}:`, error);
|
||||||
|
containers[0].innerHTML = `<div class="error">Failed to initialize port manager: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Hide labels and help text for port field containers
|
||||||
|
hidePortFieldLabels(container) {
|
||||||
|
const formField = container.closest('.form-field');
|
||||||
|
if (formField) {
|
||||||
|
// Hide the label
|
||||||
|
const label = formField.querySelector('label.form-label');
|
||||||
|
if (label) {
|
||||||
|
label.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the help text
|
||||||
|
const helpText = formField.querySelector('small.form-help');
|
||||||
|
if (helpText) {
|
||||||
|
helpText.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any help icons
|
||||||
|
const helpIcons = formField.querySelectorAll('.help-icon');
|
||||||
|
helpIcons.forEach(icon => {
|
||||||
|
icon.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Get all port configurations for an app
|
||||||
|
getAllPortConfigs(appConfig, appName) {
|
||||||
|
const portConfigs = [];
|
||||||
|
const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`;
|
||||||
|
Object.keys(appConfig).forEach(key => {
|
||||||
|
if (key.startsWith(portPrefix)) {
|
||||||
|
const configValue = appConfig[key];
|
||||||
|
if (configValue && configValue.trim() !== '') {
|
||||||
|
portConfigs.push(configValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Return as array — one CFG_<APP>_PORT_N value per element. The
|
||||||
|
// port manager iterates this directly so commas inside fields
|
||||||
|
// (multi-button labels / paths) stay meaningful and hand-editable.
|
||||||
|
return portConfigs;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
|
||||||
|
Object.assign(AppsManager.prototype, {
|
||||||
|
// Update service buttons sidebar with service data from apps-services.json
|
||||||
|
async updateServiceButtonsSidebar(app, appName) {
|
||||||
|
if (!window.serviceButtons) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update sidebar using the new ServiceButtons API
|
||||||
|
await window.serviceButtons.updateSidebar(appName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating service buttons sidebar:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Show service popup on hover
|
||||||
|
async showServicePopup(event, appName) {
|
||||||
|
if (!window.serviceButtons) return;
|
||||||
|
|
||||||
|
const popup = document.getElementById(`service-popup-${appName}`);
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load services if not already loaded
|
||||||
|
if (window.serviceButtons.services.length === 0) {
|
||||||
|
await window.serviceButtons.loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate buttons HTML
|
||||||
|
const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName);
|
||||||
|
const content = popup.querySelector('.service-popup-content');
|
||||||
|
if (content) {
|
||||||
|
content.innerHTML = buttonsHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show popup
|
||||||
|
popup.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error showing service popup:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Hide service popup
|
||||||
|
hideServicePopup() {
|
||||||
|
const popups = document.querySelectorAll('.service-popup');
|
||||||
|
popups.forEach(popup => {
|
||||||
|
popup.style.display = 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -219,7 +219,15 @@ class SystemLoader {
|
|||||||
'/components/apps/services/js/services-manager.js',
|
'/components/apps/services/js/services-manager.js',
|
||||||
'/components/apps/tools/js/tools-manager.js',
|
'/components/apps/tools/js/tools-manager.js',
|
||||||
'/components/apps/routing/js/routing-manager.js',
|
'/components/apps/routing/js/routing-manager.js',
|
||||||
'/components/apps/core/js/apps-manager.js'
|
'/components/apps/core/js/apps-manager.js', // base: class + constructor + orchestration + ServiceButtons + bootstrap (+ install-dispatch, kept inline)
|
||||||
|
// prototype-augment clusters (load after base; ordered via async=false):
|
||||||
|
'/components/apps/core/js/apps-grid.js',
|
||||||
|
'/components/apps/core/js/config-form.js',
|
||||||
|
'/components/apps/core/js/port-manager-integration.js',
|
||||||
|
'/components/apps/core/js/gluetun-vpn.js',
|
||||||
|
'/components/apps/core/js/config-dirty-guard.js',
|
||||||
|
'/components/apps/core/js/install-console.js',
|
||||||
|
'/components/apps/core/js/service-buttons-sidebar.js'
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user