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>
405 lines
14 KiB
JavaScript
Executable File
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();
|
|
}
|
|
}
|