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

405 lines
14 KiB
JavaScript
Executable File

// Loading UI - Manages the visual loading screen and user feedback
class LoadingUI {
constructor() {
this.container = null;
this.progressBar = null;
this.systemStatusContainer = null;
this.errorMessage = null;
this.retryButton = null;
this.continueButton = null;
this.isVisible = false;
this.systemCards = new Map();
}
// Initialize loading screen
initialize() {
this.createLoadingScreen();
this.attachEventListeners();
}
// Create the loading screen DOM
createLoadingScreen() {
this.container = document.createElement('div');
this.container.id = 'libreportal-loading-screen';
this.container.className = 'loading-screen aurora-bg aurora-static';
this.container.innerHTML = `
<div class="aurora-stars" aria-hidden="true"></div>
<div class="loading-container">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Drifting softly into your private universe...</p>
</div>
<div class="loading-progress">
<div class="progress-bar-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text">
<span id="progress-percentage">0%</span>
<span id="progress-details">Starting up...</span>
</div>
</div>
</div>
<div class="loading-systems" id="system-status-container">
<!-- System status cards will be inserted here -->
</div>
<div class="success-message" id="success-message" style="display: none;">
<h2>✨ Ready! ✨</h2>
<p>LibrePortal is loaded and ready to use</p>
</div>
<div class="loading-actions" id="loading-actions" style="display: none;">
<button class="btn btn-primary" id="retry-button" style="display: none;">
<span class="btn-icon">↻</span>
Retry
</button>
<button class="btn btn-secondary" id="continue-button" style="display: none;">
Continue Anyway
</button>
</div>
<div class="loading-footer">
<div class="loading-tips">
<p>💡 <span id="loading-tip">Loading essential components...</span></p>
</div>
</div>
</div>
`;
// Add to page
document.body.appendChild(this.container);
this.isVisible = true;
// Get references to elements
this.progressBar = document.getElementById('progress-fill');
this.systemStatusContainer = document.getElementById('system-status-container');
this.errorMessage = null;
this.retryButton = document.getElementById('retry-button');
this.continueButton = document.getElementById('continue-button');
// Initialize system cards
this.initializeSystemCards();
}
// Initialize system status cards
initializeSystemCards() {
const systems = [
{ id: 'core', name: 'Core System', icon: '🪐' },
{ id: 'data', name: 'Data Loading', icon: '📊' },
{ id: 'components', name: 'UI Components', icon: '🎨' },
{ id: 'task', name: 'Task System', icon: '📋' },
{ id: 'managers', name: 'System Managers', icon: '🔧' },
{ id: 'backend', name: 'Backend Services', icon: '🌐' },
{ id: 'config-validation', name: 'Config Files', icon: '🔍' },
{ id: 'update-lock', name: 'Update Lock', icon: '🔄' },
{ id: 'pause-lock', name: 'Pause Lock', icon: '⏸️' }
];
systems.forEach(system => {
const card = document.createElement('div');
card.className = 'system-card';
card.id = `system-card-${system.id}`;
card.innerHTML = `
<div class="system-icon">${system.icon}</div>
<div class="system-info">
<div class="system-name">${system.name}</div>
<div class="system-status">Waiting...</div>
</div>
<div class="system-indicator">
<div class="status-icon">⏳</div>
</div>
`;
this.systemStatusContainer.appendChild(card);
this.systemCards.set(system.id, card);
});
}
// Update progress
updateProgress(progress, details = '') {
if (this.progressBar) {
this.progressBar.style.width = `${progress}%`;
}
const percentageElement = document.getElementById('progress-percentage');
if (percentageElement) {
percentageElement.textContent = `${Math.round(progress)}%`;
}
const detailsElement = document.getElementById('progress-details');
if (detailsElement) {
detailsElement.textContent = details || `${Math.round(progress)}% complete`;
}
// Update loading tip
this.updateLoadingTip(progress);
}
// Process message to convert backticks to styled code blocks
processMessage(message) {
if (!message) return message;
// Convert backtick-wrapped text to styled code blocks
return message.replace(/`([^`]+)`/g, '<code class="command-box">$1</code>');
}
// Update system check status
updateSystemCheck(systemId, checkName, status, error = null, message = null) {
const card = this.systemCards.get(systemId);
if (!card) return;
const statusElement = card.querySelector('.system-status');
const indicatorElement = card.querySelector('.status-icon');
if (!statusElement || !indicatorElement) return;
switch (status) {
case 'checking':
statusElement.textContent = `Checking: ${checkName}`;
indicatorElement.textContent = '⏳';
card.className = 'system-card checking';
// Auto-scroll to this card when checking starts
this.scrollToCard(card);
break;
case 'retrying':
statusElement.innerHTML = `Retrying: ${checkName}`;
indicatorElement.textContent = '🔄';
card.className = 'system-card retrying';
// Don't auto-scroll during retry to prevent jumping around
if (message) {
statusElement.title = this.processMessage(message);
}
break;
case 'waiting':
statusElement.textContent = `Waiting: ${checkName}`;
indicatorElement.textContent = '⏰';
card.className = 'system-card waiting';
break;
case 'passed':
statusElement.textContent = 'Operational';
indicatorElement.textContent = '✅';
card.className = 'system-card passed';
break;
case 'failed':
statusElement.textContent = `Failed: ${checkName}`;
indicatorElement.textContent = '❌';
card.className = 'system-card failed';
if (error) {
statusElement.title = error;
}
break;
case 'skipped':
statusElement.textContent = 'Skipped';
indicatorElement.textContent = '⏭️';
card.className = 'system-card skipped';
break;
}
}
// Scroll to specific card smoothly
scrollToCard(card) {
if (!card || !this.systemStatusContainer) return;
const containerHeight = this.systemStatusContainer.clientHeight;
const cardHeight = card.offsetHeight;
const cardOffsetTop = card.offsetTop;
const containerScrollHeight = this.systemStatusContainer.scrollHeight;
// Calculate target scroll position to center the card
let targetScrollTop = cardOffsetTop - (containerHeight / 2) + (cardHeight / 2);
// Ensure we don't scroll past the bottom
const maxScrollTop = containerScrollHeight - containerHeight;
if (targetScrollTop > maxScrollTop) {
targetScrollTop = maxScrollTop;
}
// Ensure we don't scroll before the top
if (targetScrollTop < 0) {
targetScrollTop = 0;
}
// Smooth scroll to the target position
this.systemStatusContainer.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
});
}
// Update loading tip based on progress
updateLoadingTip(progress) {
const tipElement = document.getElementById('loading-tip');
if (!tipElement) return;
const tips = [
'Loading essential components...',
'Preparing your workspace...',
'Checking system dependencies...',
'Initializing data connections...',
'Almost ready...',
'Finalizing launch...'
];
const tipIndex = Math.floor((progress / 100) * tips.length);
const tip = tips[Math.min(tipIndex, tips.length - 1)];
if (tipElement.textContent !== tip) {
tipElement.textContent = tip;
}
}
// Show error message
showError(errors) {
// console.log('🔍 LoadingUI.showError called with errors:', errors.length);
// console.log('🔍 Existing error details elements:', document.querySelectorAll('.error-details').length);
// console.log('🔍 Call stack:', new Error().stack);
const actionsContainer = document.getElementById('loading-actions');
// Check if this is a missing data files issue
const hasMissingDataFiles = errors.some(error =>
error.error && (error.error.includes('Missing required data file') ||
error.error.includes('Critical configuration files are missing or empty'))
);
// Create error details
const errorDetails = document.createElement('div');
errorDetails.className = 'error-details';
if (hasMissingDataFiles) {
// Special handling for missing data files
errorDetails.innerHTML = `
<h4>🔧 LibrePortal Setup Required</h4>
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 10px 0;">
<p style="margin: 0 0 10px 0; font-weight: bold;">❌ Required data files are missing!</p>
<p style="margin: 0 0 15px 0;">LibrePortal needs to generate JSON configuration files before it can run properly.</p>
<div style="background: #2d3748; color: #e2e8f0; padding: 10px; border-radius: 4px; font-family: 'Courier New', monospace; margin: 10px 0;">
libreportal webui generate all
</div>
<p style="margin: 5px 0; font-size: 0.9em; color: #666;">
Run this command in your terminal to fix the issue.
</p>
</div>
<div class="error-list-container" style="max-height: 150px; overflow-y: auto; border: 1px solid #ff6b6b; border-radius: 4px; padding: 10px; margin: 10px 0; background: rgba(255, 107, 107, 0.1);">
<ul style="margin: 0; padding-left: 20px;">
${errors.map(error => `
<li style="margin-bottom: 8px;">
<strong>${error.system || error.checkName || 'Unknown'}:</strong>
<div style="white-space: pre-line; margin-top: 4px;">${(error.error || 'Check failed').replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>')}</div>
</li>
`).join('')}
</ul>
</div>
<p style="margin: 10px 0;">After running the setup command, refresh this page to continue.</p>
`;
} else {
// Regular error handling
errorDetails.innerHTML = `
<h4>⚠️ Loading Issues Detected</h4>
<div class="error-list-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #ff6b6b; border-radius: 4px; padding: 10px; margin: 10px 0; background: rgba(255, 107, 107, 0.1);">
<ul style="margin: 0; padding-left: 20px;">
${errors.map(error => `
<li style="margin-bottom: 8px;">
<strong>${error.system || error.checkName || 'Unknown'}:</strong>
<div style="white-space: pre-line; margin-top: 4px;">${(error.error || 'Check failed').replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>')}</div>
${error.error && error.error.includes('Timeout') ? '<small><br>Try checking your network connection and refresh.</small>' : ''}
</li>
`).join('')}
</ul>
</div>
<p>You can retry loading or continue with limited functionality</p>
`;
}
// Insert before actions
if (actionsContainer && actionsContainer.parentNode) {
actionsContainer.parentNode.insertBefore(errorDetails, actionsContainer);
actionsContainer.style.display = 'flex';
this.retryButton.style.display = 'inline-flex';
this.continueButton.style.display = 'inline-flex';
}
}
// Hide loading screen
hide() {
if (this.container && this.isVisible) {
// Show success message and trigger animations
const successMessage = document.getElementById('success-message');
if (successMessage) {
successMessage.style.display = 'block';
}
// Add success class to trigger animations
this.container.classList.add('success');
// Wait for success animations to play, then hide
setTimeout(() => {
this.container.classList.add('hiding');
setTimeout(() => {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.isVisible = false;
}, 500);
}, 200);
}
}
// Show loading screen
show() {
if (!this.isVisible && this.container) {
if (!this.container.parentNode) {
document.body.appendChild(this.container);
}
this.container.classList.remove('hiding');
this.isVisible = true;
}
}
// Update status text
updateStatus(text) {
const statusElement = document.getElementById('loading-status-text');
if (statusElement) {
statusElement.textContent = text;
}
}
// Attach event listeners
attachEventListeners() {
if (this.retryButton) {
this.retryButton.addEventListener('click', () => {
// console.log('🔄 Retry button clicked - refreshing page');
window.location.reload();
});
}
if (this.continueButton) {
this.continueButton.addEventListener('click', () => {
this.hide();
// Trigger continue event
window.dispatchEvent(new CustomEvent('loadingContinue'));
});
}
}
// Cleanup
destroy() {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.isVisible = false;
this.systemCards.clear();
}
}