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>
364 lines
13 KiB
JavaScript
Executable File
364 lines
13 KiB
JavaScript
Executable File
// App Manager - Dynamic app loading with beautiful styling
|
|
class AppManager {
|
|
constructor() {
|
|
this.cache = new Map();
|
|
}
|
|
|
|
getRandomLoadingMessage() {
|
|
const messages = [
|
|
"Preparing your application settings...",
|
|
"Gathering application information...",
|
|
"Loading application configuration...",
|
|
"Setting up your app management panel...",
|
|
"Loading the perfect app settings...",
|
|
"Crafting your application experience...",
|
|
"Preparing your app control panel...",
|
|
"Loading application details...",
|
|
"Setting up your app workspace...",
|
|
"Configuring your application environment..."
|
|
];
|
|
|
|
return messages[Math.floor(Math.random() * messages.length)];
|
|
}
|
|
|
|
async loadApp(appName) {
|
|
//console.log(`AppManager: Loading ${appName} app...`);
|
|
|
|
// Check cache first
|
|
if (this.cache.has(appName)) {
|
|
//console.log(`AppManager: Using cached ${appName} app`);
|
|
return this.cache.get(appName);
|
|
}
|
|
|
|
try {
|
|
// Load app data from apps.json
|
|
const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load apps.json: ${response.status}`);
|
|
}
|
|
|
|
const appsData = await response.json();
|
|
|
|
// Try multiple ways to find the app
|
|
let app = appsData.apps.find(app =>
|
|
app.name.toLowerCase().includes(appName.toLowerCase()) ||
|
|
app.command.toLowerCase().includes(appName.toLowerCase()) ||
|
|
app.name === appName ||
|
|
app.name.toLowerCase() === appName.toLowerCase()
|
|
);
|
|
|
|
if (!app) {
|
|
// Try case-insensitive exact match
|
|
app = appsData.apps.find(app =>
|
|
app.name.toLowerCase() === appName.toLowerCase() ||
|
|
app.command.toLowerCase().includes(appName.toLowerCase())
|
|
);
|
|
}
|
|
|
|
if (!app) {
|
|
//console.log(`Available apps:`, appsData.apps.map(a => ({ name: a.name, command: a.command })));
|
|
throw new Error(`App ${appName} not found`);
|
|
}
|
|
|
|
//console.log(`AppManager: Loaded ${appName} app:`, app);
|
|
|
|
// Cache the result
|
|
this.cache.set(appName, app);
|
|
|
|
return app;
|
|
} catch (error) {
|
|
console.error(`AppManager: Error loading ${appName} app:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async renderApp(appName) {
|
|
//console.log(`AppManager: Rendering ${appName} app...`);
|
|
|
|
const configSection = document.getElementById('config-section');
|
|
if (!configSection) {
|
|
console.error('AppManager: 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 application...
|
|
</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 app data
|
|
const app = await this.loadApp(appName);
|
|
|
|
// Update loading bar
|
|
if (typeof router !== 'undefined' && router.updateProgress) {
|
|
router.updateProgress(70);
|
|
}
|
|
|
|
if (!app) {
|
|
configSection.innerHTML = '<div class="error">Application not found</div>';
|
|
return;
|
|
}
|
|
|
|
// App config comes from apps.json (window.apps), not a separate
|
|
// per-app JSON. Pass null — the renderer's config section is gated
|
|
// on appConfig?.config keys so it just skips that section.
|
|
if (typeof router !== 'undefined' && router.updateProgress) {
|
|
router.updateProgress(80);
|
|
}
|
|
|
|
await this.renderWithOriginalStyling(appName, app, null);
|
|
|
|
// Final progress update
|
|
if (typeof router !== 'undefined' && router.updateProgress) {
|
|
router.updateProgress(80);
|
|
}
|
|
|
|
//console.log(`AppManager: Successfully rendered ${appName} app`);
|
|
|
|
} catch (error) {
|
|
console.error(`AppManager: Error rendering ${appName} app:`, error);
|
|
configSection.innerHTML = `<div class="error">Failed to load ${appName} application: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async renderWithOriginalStyling(appName, app, appConfig) {
|
|
const configSection = document.getElementById('config-section');
|
|
|
|
// Render using the original app-config system
|
|
let formHTML = `
|
|
<div class="app-title">
|
|
<h3>${app.displayName || app.name} Application</h3>
|
|
<p>${app.description || 'Manage settings and configuration for ' + (app.displayName || app.name)}</p>
|
|
</div>
|
|
<div class="app-container">
|
|
<form id="app-form-${appName}" class="app-form">
|
|
`;
|
|
|
|
// App information section
|
|
formHTML += `
|
|
<div class="app-category">
|
|
<h3>Application Information</h3>
|
|
<p class="category-description">Basic information about this application</p>
|
|
<div class="config-group">
|
|
<div class="config-field">
|
|
<label class="config-label">Application Name</label>
|
|
<div class="config-input-wrapper">
|
|
<input type="text" value="${app.displayName || app.name}" readonly class="config-input">
|
|
</div>
|
|
</div>
|
|
<div class="config-field">
|
|
<label class="config-label">Status</label>
|
|
<div class="config-input-wrapper">
|
|
<input type="text" value="${app.installed ? 'Installed' : 'Not Installed'}" readonly class="config-input">
|
|
</div>
|
|
</div>
|
|
<div class="config-field">
|
|
<label class="config-label">Version</label>
|
|
<div class="config-input-wrapper">
|
|
<input type="text" value="${app.version || 'N/A'}" readonly class="config-input">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Configuration section if available
|
|
if (appConfig && appConfig.config && Object.keys(appConfig.config).length > 0) {
|
|
// Use ConfigShared if available for beautiful rendering
|
|
if (typeof ConfigShared !== 'undefined') {
|
|
const groupedConfigs = ConfigShared.groupConfigKeys(appConfig.config);
|
|
const categoryOrder = ConfigShared.extractCategoryOrder(appConfig.config);
|
|
|
|
for (const category of categoryOrder) {
|
|
const keys = groupedConfigs[category];
|
|
if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') {
|
|
const displayCategory = ConfigShared.formatCategoryName(category);
|
|
const categoryDescription = await ConfigShared.getCategoryDescription(category);
|
|
|
|
formHTML += `
|
|
<div class="config-category">
|
|
<h3>${displayCategory}</h3>
|
|
<p class="category-description">${categoryDescription}</p>
|
|
<div class="config-group">
|
|
${ConfigShared.generateFieldsForCategory(keys, category, appConfig.config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback simple rendering
|
|
formHTML += `
|
|
<div class="config-category">
|
|
<h3>Configuration</h3>
|
|
<p class="category-description">Application-specific settings</p>
|
|
<div class="config-group">
|
|
<div class="config-field">
|
|
<label class="config-label">Configuration Available</label>
|
|
<div class="config-input-wrapper">
|
|
<input type="text" value="Configuration loaded successfully" readonly class="config-input">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} else {
|
|
// No configuration available
|
|
formHTML += `
|
|
<div class="config-category">
|
|
<h3>Configuration</h3>
|
|
<p class="category-description">No specific configuration available for this application</p>
|
|
<div class="config-group">
|
|
<div class="config-field">
|
|
<label class="config-label">Status</label>
|
|
<div class="config-input-wrapper">
|
|
<input type="text" value="No custom configuration needed" readonly class="config-input">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
formHTML += `
|
|
</form>
|
|
<div class="config-actions">
|
|
<button type="button" class="btn btn-primary" onclick="AppManager.saveAppConfig('${appName}')">
|
|
<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="AppManager.resetAppConfig('${appName}')">
|
|
<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;
|
|
}
|
|
|
|
static async saveAppConfig(appName) {
|
|
//console.log(`AppManager: Saving ${appName} config...`);
|
|
|
|
const form = document.getElementById(`app-form-${appName}`);
|
|
if (!form) {
|
|
console.error('AppManager: Form not found');
|
|
return;
|
|
}
|
|
|
|
// Show success message
|
|
if (typeof ConfigShared !== 'undefined' && ConfigShared.showNotification) {
|
|
ConfigShared.showNotification('Application configuration saved successfully!', 'success');
|
|
} else {
|
|
// Fallback message
|
|
const message = document.createElement('div');
|
|
message.className = 'config-message success';
|
|
message.textContent = 'Application configuration saved successfully!';
|
|
|
|
const actionsDiv = form.parentElement.querySelector('.config-actions');
|
|
actionsDiv.insertBefore(message, actionsDiv.firstChild);
|
|
|
|
// Remove message after 3 seconds
|
|
setTimeout(() => {
|
|
if (message.parentNode) {
|
|
message.parentNode.removeChild(message);
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
static async resetAppConfig(appName) {
|
|
//console.log(`AppManager: Resetting ${appName} config...`);
|
|
|
|
if (confirm('Are you sure you want to reset all settings to their default values?')) {
|
|
// Reload the page to reset
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
async loadScript(src) {
|
|
// Check if script is already loaded
|
|
const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_');
|
|
if (document.getElementById(scriptId)) {
|
|
//console.log(`Script ${src} already loaded, skipping`);
|
|
return;
|
|
}
|
|
|
|
//console.log(`Loading script: ${src}`);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.id = scriptId;
|
|
script.onload = () => {
|
|
//console.log(`Script loaded successfully: ${src}`);
|
|
resolve();
|
|
};
|
|
script.onerror = (error) => {
|
|
console.error(`Script failed to load: ${src}`, error);
|
|
reject(new Error(`Failed to load script: ${src}`));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Global instance
|
|
window.appManager = new AppManager();
|