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

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();