status updates and album card download status
This commit is contained in:
parent
4ffdf12460
commit
85d7dce943
3 changed files with 297 additions and 12 deletions
|
|
@ -16,10 +16,14 @@ from pathlib import Path
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response
|
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
# --- Core Application Imports ---
|
# --- Core Application Imports ---
|
||||||
# Import the same core clients and config manager used by the GUI app
|
# Import the same core clients and config manager used by the GUI app
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
# Initialize logger
|
||||||
|
logger = get_logger("web_server")
|
||||||
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
||||||
from core.plex_client import PlexClient
|
from core.plex_client import PlexClient
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
|
|
@ -1635,6 +1639,46 @@ def get_recent_toasts():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/logs')
|
||||||
|
def get_activity_logs():
|
||||||
|
"""Get formatted activity feed for display in sync page log area"""
|
||||||
|
try:
|
||||||
|
with activity_feed_lock:
|
||||||
|
# Get the last 50 activities (more than the dashboard shows)
|
||||||
|
recent_activities = activity_feed[-50:] if len(activity_feed) > 50 else activity_feed[:]
|
||||||
|
|
||||||
|
# Reverse order so newest appears at top
|
||||||
|
recent_activities = recent_activities[::-1]
|
||||||
|
|
||||||
|
# Format activities as readable log entries
|
||||||
|
formatted_logs = []
|
||||||
|
|
||||||
|
if not recent_activities:
|
||||||
|
formatted_logs = [
|
||||||
|
"No recent activity.",
|
||||||
|
"Sync and download operations will appear here in real-time."
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
for activity in recent_activities:
|
||||||
|
# Format: [TIME] ICON TITLE - SUBTITLE
|
||||||
|
timestamp = activity.get('time', 'Unknown')
|
||||||
|
icon = activity.get('icon', '•')
|
||||||
|
title = activity.get('title', 'Activity')
|
||||||
|
subtitle = activity.get('subtitle', '')
|
||||||
|
|
||||||
|
# Create a clean, readable log entry
|
||||||
|
if subtitle:
|
||||||
|
log_entry = f"[{timestamp}] {icon} {title} - {subtitle}"
|
||||||
|
else:
|
||||||
|
log_entry = f"[{timestamp}] {icon} {title}"
|
||||||
|
|
||||||
|
formatted_logs.append(log_entry)
|
||||||
|
|
||||||
|
return jsonify({'logs': formatted_logs})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'logs': [f'Error reading activity feed: {str(e)}']})
|
||||||
|
|
||||||
def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now", show_toast: bool = True):
|
def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now", show_toast: bool = True):
|
||||||
"""Add activity item to the feed (replicates dashboard.py functionality)"""
|
"""Add activity item to the feed (replicates dashboard.py functionality)"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1928,8 +1972,8 @@ def search_music():
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify({"error": "No search query provided."}), 400
|
return jsonify({"error": "No search query provided."}), 400
|
||||||
|
|
||||||
print(f"Web UI Search for: '{query}'")
|
logger.info(f"Web UI Search initiated for: '{query}'")
|
||||||
|
|
||||||
# Add activity for search start
|
# Add activity for search start
|
||||||
add_activity_item("🔍", "Search Started", f"'{query}'", "Now")
|
add_activity_item("🔍", "Search Started", f"'{query}'", "Now")
|
||||||
|
|
||||||
|
|
@ -1989,11 +2033,12 @@ def start_download():
|
||||||
if download_id:
|
if download_id:
|
||||||
started_downloads += 1
|
started_downloads += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to start track download: {e}")
|
logger.error(f"Failed to start track download: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add activity for album download start
|
# Add activity for album download start
|
||||||
album_name = data.get('album_name', 'Unknown Album')
|
album_name = data.get('album_name', 'Unknown Album')
|
||||||
|
logger.info(f"📥 Starting album download: '{album_name}' with {started_downloads}/{len(tracks)} tracks")
|
||||||
add_activity_item("📥", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now")
|
add_activity_item("📥", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -2015,13 +2060,15 @@ def start_download():
|
||||||
if download_id:
|
if download_id:
|
||||||
# Extract track name from filename for activity
|
# Extract track name from filename for activity
|
||||||
track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename
|
track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename
|
||||||
|
logger.info(f"📥 Starting single track download: '{track_name}'")
|
||||||
add_activity_item("📥", "Track Download Started", f"'{track_name}'", "Now")
|
add_activity_item("📥", "Track Download Started", f"'{track_name}'", "Now")
|
||||||
return jsonify({"success": True, "message": "Download started"})
|
return jsonify({"success": True, "message": "Download started"})
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to start download for: {filename}")
|
||||||
return jsonify({"error": "Failed to start download"}), 500
|
return jsonify({"error": "Failed to start download"}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Download error: {e}")
|
logger.error(f"Download error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10147,8 +10194,9 @@ def start_playlist_sync():
|
||||||
|
|
||||||
# Add activity for sync start
|
# Add activity for sync start
|
||||||
add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now")
|
add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now")
|
||||||
|
|
||||||
print(f"⏱️ [TIMING] Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)")
|
logger.info(f"🔄 Starting playlist sync for '{playlist_name}' with {len(tracks_json)} tracks")
|
||||||
|
logger.debug(f"Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)")
|
||||||
|
|
||||||
with sync_lock:
|
with sync_lock:
|
||||||
if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done():
|
if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done():
|
||||||
|
|
@ -11689,6 +11737,12 @@ def start_oauth_callback_servers():
|
||||||
print("✅ OAuth callback servers started")
|
print("✅ OAuth callback servers started")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
# Initialize logging for web server
|
||||||
|
from utils.logging_config import setup_logging
|
||||||
|
log_level = config_manager.get('logging.level', 'INFO')
|
||||||
|
log_path = config_manager.get('logging.path', 'logs/app.log')
|
||||||
|
logger = setup_logging(log_level, log_path)
|
||||||
|
|
||||||
print("🚀 Starting SoulSync Web UI Server...")
|
print("🚀 Starting SoulSync Web UI Server...")
|
||||||
print("Open your browser and navigate to http://127.0.0.1:8008")
|
print("Open your browser and navigate to http://127.0.0.1:8008")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,7 @@ async function loadPageData(pageId) {
|
||||||
stopDbStatsPolling();
|
stopDbStatsPolling();
|
||||||
stopDbUpdatePolling();
|
stopDbUpdatePolling();
|
||||||
stopWishlistCountPolling();
|
stopWishlistCountPolling();
|
||||||
|
stopLogPolling();
|
||||||
switch (pageId) {
|
switch (pageId) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
await loadDashboardData();
|
await loadDashboardData();
|
||||||
|
|
@ -3560,7 +3561,20 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
process.status = 'running';
|
process.status = 'running';
|
||||||
updatePlaylistCardUI(playlistId);
|
updatePlaylistCardUI(playlistId);
|
||||||
updateRefreshButtonState();
|
updateRefreshButtonState();
|
||||||
|
|
||||||
|
// Set album to downloading status if this is an artist album
|
||||||
|
if (playlistId.startsWith('artist_album_')) {
|
||||||
|
// Format: artist_album_{artist.id}_{album.id}
|
||||||
|
const parts = playlistId.split('_');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const albumId = parts.slice(3).join('_'); // In case album ID has underscores
|
||||||
|
const totalTracks = process.tracks ? process.tracks.length : 0;
|
||||||
|
setAlbumDownloadingStatus(albumId, 0, totalTracks);
|
||||||
|
console.log(`🔄 Set album ${albumId} to downloading status (0/${totalTracks} tracks)`);
|
||||||
|
console.log(`🔍 Virtual playlist ID: ${playlistId} → Album ID: ${albumId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
|
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
|
||||||
if (playlistId.startsWith('youtube_')) {
|
if (playlistId.startsWith('youtube_')) {
|
||||||
const urlHash = playlistId.replace('youtube_', '');
|
const urlHash = playlistId.replace('youtube_', '');
|
||||||
|
|
@ -4022,6 +4036,15 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
process.status = 'complete';
|
process.status = 'complete';
|
||||||
updatePlaylistCardUI(playlistId);
|
updatePlaylistCardUI(playlistId);
|
||||||
|
|
||||||
|
// Set album to downloaded status if this is an artist album
|
||||||
|
if (playlistId.startsWith('artist_album_')) {
|
||||||
|
const parts = playlistId.split('_');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const albumId = parts.slice(3).join('_');
|
||||||
|
setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show completion message
|
// Show completion message
|
||||||
const completionMessage = `Download complete! ${completedCount} downloaded, ${failedOrCancelledCount} failed.`;
|
const completionMessage = `Download complete! ${completedCount} downloaded, ${failedOrCancelledCount} failed.`;
|
||||||
showToast(completionMessage, 'success');
|
showToast(completionMessage, 'success');
|
||||||
|
|
@ -8509,6 +8532,9 @@ function initializeSyncPage() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize live log viewer
|
||||||
|
initializeLiveLogViewer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10562,8 +10588,8 @@ function updateAlbumCompletionOverlay(completionData, containerType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing status classes
|
// Remove existing status classes
|
||||||
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error');
|
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error');
|
||||||
|
|
||||||
// Add new status class
|
// Add new status class
|
||||||
overlay.classList.add(completionData.status);
|
overlay.classList.add(completionData.status);
|
||||||
|
|
||||||
|
|
@ -10600,6 +10626,10 @@ function getCompletionStatusText(completionData) {
|
||||||
return 'Partial';
|
return 'Partial';
|
||||||
case 'missing':
|
case 'missing':
|
||||||
return 'Missing';
|
return 'Missing';
|
||||||
|
case 'downloading':
|
||||||
|
return 'Downloading...';
|
||||||
|
case 'downloaded':
|
||||||
|
return 'Downloaded';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Error';
|
return 'Error';
|
||||||
default:
|
default:
|
||||||
|
|
@ -10607,6 +10637,71 @@ function getCompletionStatusText(completionData) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set album to downloaded status after download finishes
|
||||||
|
*/
|
||||||
|
function setAlbumDownloadedStatus(albumId) {
|
||||||
|
console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`);
|
||||||
|
|
||||||
|
const completionData = {
|
||||||
|
id: albumId,
|
||||||
|
status: 'downloaded',
|
||||||
|
owned_tracks: 0,
|
||||||
|
expected_tracks: 0,
|
||||||
|
name: 'Downloaded',
|
||||||
|
completion_percentage: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find if it's in albums or singles container
|
||||||
|
let containerType = 'albums';
|
||||||
|
let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
|
||||||
|
if (!albumCard) {
|
||||||
|
containerType = 'singles';
|
||||||
|
albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumCard) {
|
||||||
|
updateAlbumCompletionOverlay(completionData, containerType);
|
||||||
|
console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`);
|
||||||
|
} else {
|
||||||
|
console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set album to downloading status
|
||||||
|
*/
|
||||||
|
function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
|
||||||
|
console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`);
|
||||||
|
|
||||||
|
const completionData = {
|
||||||
|
id: albumId,
|
||||||
|
status: 'downloading',
|
||||||
|
owned_tracks: downloaded,
|
||||||
|
expected_tracks: total,
|
||||||
|
name: 'Downloading',
|
||||||
|
completion_percentage: Math.round((downloaded / total) * 100) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find if it's in albums or singles container
|
||||||
|
let containerType = 'albums';
|
||||||
|
let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
|
||||||
|
if (!albumCard) {
|
||||||
|
containerType = 'singles';
|
||||||
|
albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumCard) {
|
||||||
|
console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`);
|
||||||
|
updateAlbumCompletionOverlay(completionData, containerType);
|
||||||
|
} else {
|
||||||
|
console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`);
|
||||||
|
// Debug: List all available album cards
|
||||||
|
const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]');
|
||||||
|
console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show error state on all completion overlays
|
* Show error state on all completion overlays
|
||||||
*/
|
*/
|
||||||
|
|
@ -13020,5 +13115,98 @@ async function checkAndRestoreMetadataUpdateState() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Live Log Viewer Functions ---
|
||||||
|
|
||||||
|
// Global state for log polling
|
||||||
|
let logPolling = false;
|
||||||
|
let logInterval = null;
|
||||||
|
let lastLogCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the live log viewer for sync page
|
||||||
|
*/
|
||||||
|
function initializeLiveLogViewer() {
|
||||||
|
const logArea = document.getElementById('sync-log-area');
|
||||||
|
if (!logArea) return;
|
||||||
|
|
||||||
|
// Set initial content
|
||||||
|
logArea.value = 'Loading activity feed...';
|
||||||
|
|
||||||
|
// Start log polling
|
||||||
|
startLogPolling();
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for logs
|
||||||
|
*/
|
||||||
|
function startLogPolling() {
|
||||||
|
if (logPolling) return; // Already polling
|
||||||
|
|
||||||
|
logPolling = true;
|
||||||
|
logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds
|
||||||
|
console.log('📝 Started activity feed polling for sync page');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop polling for logs
|
||||||
|
*/
|
||||||
|
function stopLogPolling() {
|
||||||
|
logPolling = false;
|
||||||
|
if (logInterval) {
|
||||||
|
clearInterval(logInterval);
|
||||||
|
logInterval = null;
|
||||||
|
console.log('📝 Stopped log polling');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and display activity feed as logs
|
||||||
|
*/
|
||||||
|
async function loadLogs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/logs');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.logs && Array.isArray(data.logs)) {
|
||||||
|
const logArea = document.getElementById('sync-log-area');
|
||||||
|
if (!logArea) return;
|
||||||
|
|
||||||
|
// Join logs with newlines and update textarea
|
||||||
|
const logText = data.logs.join('\n');
|
||||||
|
|
||||||
|
// Store current scroll state
|
||||||
|
const wasAtTop = logArea.scrollTop <= 10;
|
||||||
|
const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10;
|
||||||
|
|
||||||
|
// Update content only if it has changed
|
||||||
|
if (logArea.value !== logText) {
|
||||||
|
logArea.value = logText;
|
||||||
|
|
||||||
|
// Smart scrolling: stay at top for new entries, preserve user position if scrolled
|
||||||
|
if (wasAtTop || !wasUserScrolled) {
|
||||||
|
logArea.scrollTop = 0; // Stay at top since newest entries are now at top
|
||||||
|
}
|
||||||
|
// If user had scrolled, keep their position (browser handles this automatically)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load activity logs for sync page:', error);
|
||||||
|
const logArea = document.getElementById('sync-log-area');
|
||||||
|
if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) {
|
||||||
|
logArea.value = 'Error loading activity feed. Check console for details.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop log polling when leaving sync page
|
||||||
|
*/
|
||||||
|
function cleanupSyncPageLogs() {
|
||||||
|
stopLogPolling();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Global Cleanup on Page Unload ---
|
// --- Global Cleanup on Page Unload ---
|
||||||
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|
||||||
|
|
@ -6790,17 +6790,60 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.completion-overlay.missing {
|
.completion-overlay.missing {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg,
|
||||||
rgba(108, 117, 125, 0.9) 0%,
|
rgba(108, 117, 125, 0.9) 0%,
|
||||||
rgba(73, 80, 87, 0.95) 100%);
|
rgba(73, 80, 87, 0.95) 100%);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
border-color: rgba(108, 117, 125, 0.6);
|
border-color: rgba(108, 117, 125, 0.6);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
0 0 0 1px rgba(108, 117, 125, 0.2),
|
0 0 0 1px rgba(108, 117, 125, 0.2),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.completion-overlay.downloading {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(255, 165, 0, 0.9) 0%,
|
||||||
|
rgba(255, 140, 0, 0.95) 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(255, 165, 0, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(255, 165, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
animation: downloadingPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes downloadingPulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(255, 165, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(255, 165, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.3),
|
||||||
|
0 0 12px rgba(255, 165, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-overlay.downloaded {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(40, 167, 69, 0.9) 0%,
|
||||||
|
rgba(34, 139, 58, 0.95) 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(40, 167, 69, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(40, 167, 69, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.completion-overlay.error {
|
.completion-overlay.error {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg,
|
||||||
rgba(220, 53, 69, 0.9) 0%,
|
rgba(220, 53, 69, 0.9) 0%,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue