feat: add loading page with real-time initialization progress
- Create loading.html template with WebSocket-based progress updates - Update initialization_service to emit progress events via ProgressService - Modify setup endpoint to run initialization in background and redirect to loading page - Add /loading route in page_controller - Show real-time progress for series sync, NFO scan, and media scan steps - Display completion message with button to continue to app - Handle errors with visual feedback
This commit is contained in:
487
src/server/web/templates/loading.html
Normal file
487
src/server/web/templates/loading.html
Normal file
@@ -0,0 +1,487 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Initializing</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.loading-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.loading-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-header .logo {
|
||||
font-size: 3.5rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-header h1 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
border-left: 4px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-step.active {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.progress-step.completed {
|
||||
border-left-color: var(--color-success);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progress-step.error {
|
||||
border-left-color: var(--color-error);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 1.2rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-icon.loading {
|
||||
color: var(--color-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.step-icon.completed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.step-icon.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.step-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: 2rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.step-progress {
|
||||
margin-left: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-error-light);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.completion-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.completion-message i {
|
||||
font-size: 3rem;
|
||||
color: var(--color-success);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.completion-message h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.completion-message p {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-continue {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-continue:hover {
|
||||
background: var(--color-primary-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="loading-container">
|
||||
<div class="loading-card">
|
||||
<div class="loading-header">
|
||||
<div class="logo">
|
||||
<i class="fas fa-tv"></i>
|
||||
</div>
|
||||
<h1>Initializing AniWorld Manager</h1>
|
||||
<p>Please wait while we set up your anime library...</p>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<!-- Steps will be dynamically added here -->
|
||||
</div>
|
||||
|
||||
<div class="connection-status" id="connectionStatus">
|
||||
<i class="fas fa-circle-notch fa-spin"></i> Connecting...
|
||||
</div>
|
||||
|
||||
<div class="completion-message" id="completionMessage" style="display: none;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<h2>Initialization Complete!</h2>
|
||||
<p>Your anime library is ready to use.</p>
|
||||
<button class="btn-continue" onclick="continueToApp()">
|
||||
<i class="fas fa-arrow-right"></i> Continue to AniWorld Manager
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="errorMessage" style="display: none;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
const steps = new Map();
|
||||
let isComplete = false;
|
||||
|
||||
const stepOrder = [
|
||||
'series_sync',
|
||||
'nfo_scan',
|
||||
'media_scan'
|
||||
];
|
||||
|
||||
const stepTitles = {
|
||||
'series_sync': 'Syncing Series Database',
|
||||
'nfo_scan': 'Processing NFO Metadata',
|
||||
'media_scan': 'Scanning Media Files'
|
||||
};
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/progress`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
updateConnectionStatus(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Progress update:', data);
|
||||
handleProgressUpdate(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateConnectionStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
updateConnectionStatus(false);
|
||||
|
||||
// Reconnect after delay if not complete
|
||||
if (!isComplete) {
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
const statusEl = document.getElementById('connectionStatus');
|
||||
if (connected) {
|
||||
statusEl.className = 'connection-status connected';
|
||||
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
|
||||
} else {
|
||||
statusEl.className = 'connection-status disconnected';
|
||||
statusEl.innerHTML = '<i class="fas fa-times-circle"></i> Disconnected - Reconnecting...';
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressUpdate(data) {
|
||||
const { type, status, title, message, percent, current, total, metadata } = data;
|
||||
|
||||
// Determine step ID based on type and metadata
|
||||
let stepId = metadata?.step_id || type;
|
||||
|
||||
// Update or create step
|
||||
if (!steps.has(stepId)) {
|
||||
createStep(stepId, title || stepTitles[stepId] || stepId);
|
||||
}
|
||||
|
||||
updateStep(stepId, status, message, percent, current, total);
|
||||
|
||||
// Check for completion
|
||||
if (metadata?.initialization_complete) {
|
||||
showCompletion();
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (status === 'failed') {
|
||||
showError(message || 'An error occurred during initialization');
|
||||
}
|
||||
}
|
||||
|
||||
function createStep(stepId, title) {
|
||||
const container = document.getElementById('progressContainer');
|
||||
|
||||
const stepEl = document.createElement('div');
|
||||
stepEl.id = `step-${stepId}`;
|
||||
stepEl.className = 'progress-step';
|
||||
stepEl.innerHTML = `
|
||||
<div class="step-header">
|
||||
<i class="fas fa-circle-notch fa-spin step-icon loading"></i>
|
||||
<span class="step-title">${title}</span>
|
||||
<span class="step-status">Waiting...</span>
|
||||
</div>
|
||||
<div class="step-message"></div>
|
||||
<div class="step-progress" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div class="progress-text">0%</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert in correct order
|
||||
const existingSteps = Array.from(steps.keys());
|
||||
let insertBefore = null;
|
||||
|
||||
for (const orderId of stepOrder) {
|
||||
if (orderId === stepId) break;
|
||||
if (!existingSteps.includes(orderId)) {
|
||||
continue;
|
||||
}
|
||||
insertBefore = null;
|
||||
}
|
||||
|
||||
for (const orderId of stepOrder.slice(stepOrder.indexOf(stepId) + 1)) {
|
||||
const el = document.getElementById(`step-${orderId}`);
|
||||
if (el) {
|
||||
insertBefore = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertBefore) {
|
||||
container.insertBefore(stepEl, insertBefore);
|
||||
} else {
|
||||
container.appendChild(stepEl);
|
||||
}
|
||||
|
||||
steps.set(stepId, stepEl);
|
||||
}
|
||||
|
||||
function updateStep(stepId, status, message, percent, current, total) {
|
||||
const stepEl = steps.get(stepId);
|
||||
if (!stepEl) return;
|
||||
|
||||
const iconEl = stepEl.querySelector('.step-icon');
|
||||
const statusEl = stepEl.querySelector('.step-status');
|
||||
const messageEl = stepEl.querySelector('.step-message');
|
||||
const progressEl = stepEl.querySelector('.step-progress');
|
||||
const progressFillEl = stepEl.querySelector('.progress-bar-fill');
|
||||
const progressTextEl = stepEl.querySelector('.progress-text');
|
||||
|
||||
// Update status
|
||||
stepEl.className = 'progress-step';
|
||||
if (status === 'started' || status === 'in_progress') {
|
||||
stepEl.classList.add('active');
|
||||
iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading';
|
||||
statusEl.textContent = 'In Progress...';
|
||||
} else if (status === 'completed') {
|
||||
stepEl.classList.add('completed');
|
||||
iconEl.className = 'fas fa-check-circle step-icon completed';
|
||||
statusEl.textContent = 'Complete';
|
||||
} else if (status === 'failed') {
|
||||
stepEl.classList.add('error');
|
||||
iconEl.className = 'fas fa-exclamation-circle step-icon error';
|
||||
statusEl.textContent = 'Failed';
|
||||
}
|
||||
|
||||
// Update message
|
||||
if (message) {
|
||||
messageEl.textContent = message;
|
||||
messageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
if (percent > 0 || (current > 0 && total > 0)) {
|
||||
const actualPercent = percent || (current / total * 100);
|
||||
progressEl.style.display = 'block';
|
||||
progressFillEl.style.width = `${actualPercent}%`;
|
||||
progressTextEl.textContent = `${Math.round(actualPercent)}% (${current}/${total})`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCompletion() {
|
||||
isComplete = true;
|
||||
document.getElementById('completionMessage').style.display = 'block';
|
||||
document.getElementById('connectionStatus').style.display = 'none';
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('errorMessage');
|
||||
const errorTextEl = document.getElementById('errorText');
|
||||
errorTextEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function continueToApp() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Start WebSocket connection when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
connectWebSocket();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -703,10 +703,18 @@
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'ok') {
|
||||
showMessage('Setup completed successfully! Redirecting to login...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 2000);
|
||||
// Redirect to loading page if provided, otherwise go to login
|
||||
if (data.redirect) {
|
||||
showMessage('Setup saved! Initializing your anime library...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect;
|
||||
}, 500);
|
||||
} else {
|
||||
showMessage('Setup completed successfully! Redirecting to login...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = data.detail || data.message || 'Setup failed';
|
||||
showMessage(errorMessage, 'error');
|
||||
|
||||
Reference in New Issue
Block a user