From c371e07e814e44b0ad752f28e8557c7e47b218a8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Feb 2026 18:52:24 +0300 Subject: [PATCH] Add circular progress indicator with percentage for capture tests - Replace simple spinner with SVG circular progress ring - Show progress percentage (0-100%) in center of ring - Animate progress based on capture duration - Update every 100ms for smooth animation - Clean up timer on completion or cancellation UI improvements: - Full-page overlay with blur backdrop - Large percentage display (28px) - Progress ring uses primary color - Clean, minimal design Co-Authored-By: Claude Sonnet 4.5 --- server/src/wled_controller/static/app.js | 141 ++++++-- server/src/wled_controller/static/style.css | 361 +++++++++++++++++++- 2 files changed, 469 insertions(+), 33 deletions(-) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index fa16c8d..f304127 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -2328,6 +2328,115 @@ function closeTemplateModal() { currentEditingTemplateId = null; } +// Show full-page overlay spinner with progress +function showOverlaySpinner(text, duration = 0) { + // Remove existing overlay if any + const existing = document.getElementById('overlay-spinner'); + if (existing) { + // Clear any existing timer + if (window.overlaySpinnerTimer) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + existing.remove(); + } + + // Create overlay + const overlay = document.createElement('div'); + overlay.id = 'overlay-spinner'; + overlay.className = 'overlay-spinner'; + + // Create progress container + const progressContainer = document.createElement('div'); + progressContainer.className = 'progress-container'; + + // Create SVG progress ring + const radius = 56; + const circumference = 2 * Math.PI * radius; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '120'); + svg.setAttribute('height', '120'); + svg.setAttribute('class', 'progress-ring'); + + // Background circle + const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bgCircle.setAttribute('class', 'progress-ring-bg'); + bgCircle.setAttribute('cx', '60'); + bgCircle.setAttribute('cy', '60'); + bgCircle.setAttribute('r', radius); + + // Progress circle + const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + progressCircle.setAttribute('class', 'progress-ring-circle'); + progressCircle.setAttribute('cx', '60'); + progressCircle.setAttribute('cy', '60'); + progressCircle.setAttribute('r', radius); + progressCircle.style.strokeDasharray = circumference; + progressCircle.style.strokeDashoffset = circumference; + + svg.appendChild(bgCircle); + svg.appendChild(progressCircle); + + // Create progress content (percentage display) + const progressContent = document.createElement('div'); + progressContent.className = 'progress-content'; + + const progressPercentage = document.createElement('div'); + progressPercentage.className = 'progress-percentage'; + progressPercentage.textContent = '0%'; + + progressContent.appendChild(progressPercentage); + + progressContainer.appendChild(svg); + progressContainer.appendChild(progressContent); + + // Create text + const spinnerText = document.createElement('div'); + spinnerText.className = 'spinner-text'; + spinnerText.textContent = text; + + overlay.appendChild(progressContainer); + overlay.appendChild(spinnerText); + document.body.appendChild(overlay); + + // Animate progress if duration is provided + if (duration > 0) { + const startTime = Date.now(); + + window.overlaySpinnerTimer = setInterval(() => { + const elapsed = (Date.now() - startTime) / 1000; + const progress = Math.min(elapsed / duration, 1); + const percentage = Math.round(progress * 100); + + // Update progress ring + const offset = circumference - (progress * circumference); + progressCircle.style.strokeDashoffset = offset; + + // Update percentage display + progressPercentage.textContent = `${percentage}%`; + + // Stop timer if complete + if (progress >= 1) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + }, 100); + } +} + +// Hide full-page overlay spinner +function hideOverlaySpinner() { + // Clear timer if exists + if (window.overlaySpinnerTimer) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + + const overlay = document.getElementById('overlay-spinner'); + if (overlay) overlay.remove(); +} + // Update capture duration and save to localStorage function updateCaptureDuration(value) { document.getElementById('test-template-duration-value').textContent = value; @@ -2546,26 +2655,8 @@ async function runTemplateTest() { const template = window.currentTestingTemplate; const resultsDiv = document.getElementById('test-template-results'); - // Show loading state without destroying the structure - const loadingDiv = document.createElement('div'); - loadingDiv.className = 'loading'; - loadingDiv.textContent = t('templates.test.running'); - loadingDiv.style.position = 'absolute'; - loadingDiv.style.inset = '0'; - loadingDiv.style.background = 'var(--bg-primary)'; - loadingDiv.style.display = 'flex'; - loadingDiv.style.alignItems = 'center'; - loadingDiv.style.justifyContent = 'center'; - loadingDiv.style.zIndex = '10'; - loadingDiv.id = 'test-loading-overlay'; - - // Remove old loading overlay if exists - const oldLoading = document.getElementById('test-loading-overlay'); - if (oldLoading) oldLoading.remove(); - - resultsDiv.style.display = 'block'; - resultsDiv.style.position = 'relative'; - resultsDiv.appendChild(loadingDiv); + // Show full-page overlay spinner with progress + showOverlaySpinner(t('templates.test.running'), captureDuration); try { const response = await fetchWithAuth('/capture-templates/test', { @@ -2587,9 +2678,8 @@ async function runTemplateTest() { displayTestResults(result); } catch (error) { console.error('Error running test:', error); - // Remove loading overlay - const loadingOverlay = document.getElementById('test-loading-overlay'); - if (loadingOverlay) loadingOverlay.remove(); + // Hide overlay spinner + hideOverlaySpinner(); // Show short error in snack, details are in console showToast(t('templates.test.error.failed'), 'error'); } @@ -2599,9 +2689,8 @@ async function runTemplateTest() { function displayTestResults(result) { const resultsDiv = document.getElementById('test-template-results'); - // Remove loading overlay - const loadingOverlay = document.getElementById('test-loading-overlay'); - if (loadingOverlay) loadingOverlay.remove(); + // Hide overlay spinner + hideOverlaySpinner(); // Full capture preview const previewImg = document.getElementById('test-template-preview-image'); diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 9bac17b..7eac7ef 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -17,6 +17,7 @@ --card-bg: #2d2d2d; --text-color: #e0e0e0; --border-color: #404040; + --display-badge-bg: rgba(0, 0, 0, 0.4); } /* Light theme */ @@ -25,6 +26,7 @@ --card-bg: #ffffff; --text-color: #333333; --border-color: #e0e0e0; + --display-badge-bg: rgba(255, 255, 255, 0.85); } /* Default to dark theme */ @@ -548,7 +550,7 @@ section { font-size: 0.85rem; font-weight: 600; color: var(--text-color); - background: rgba(0, 0, 0, 0.4); + background: var(--display-badge-bg); padding: 1px 6px; border-radius: 4px; letter-spacing: 0.5px; @@ -690,6 +692,11 @@ select { transition: border-color 0.2s, box-shadow 0.2s; } +input[type="range"] { + width: 100%; + margin: 8px 0; +} + /* Better password field appearance */ input[type="password"] { letter-spacing: 0.15em; @@ -717,21 +724,98 @@ input:-webkit-autofill:focus { color: #999; } +/* Full-page overlay spinner */ +.overlay-spinner { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(4px); +} + +.overlay-spinner .progress-container { + position: relative; + width: 120px; + height: 120px; +} + +.overlay-spinner .progress-ring { + transform: rotate(-90deg); +} + +.overlay-spinner .progress-ring-circle { + transition: stroke-dashoffset 0.1s linear; + stroke: var(--primary-color); + stroke-width: 4; + fill: transparent; +} + +.overlay-spinner .progress-ring-bg { + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 4; + fill: transparent; +} + +.overlay-spinner .progress-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.overlay-spinner .progress-percentage { + color: white; + font-size: 28px; + font-weight: 600; +} + +.overlay-spinner .spinner-text { + margin-top: 24px; + color: white; + font-size: 16px; + font-weight: 500; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + .toast { position: fixed; - bottom: 20px; - right: 20px; - padding: 15px 20px; - border-radius: 4px; + bottom: 40px; + left: 50%; + transform: translateX(-50%) translateY(100px); + padding: 16px 24px; + border-radius: 8px; color: white; font-weight: 600; + font-size: 15px; opacity: 0; - transition: opacity 0.3s; - z-index: 1000; + transition: opacity 0.3s, transform 0.3s; + z-index: 2001; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); + min-width: 300px; + text-align: center; } .toast.show { opacity: 1; + transform: translateX(-50%) translateY(0); + animation: toastShake 0.5s ease-in-out; +} + +@keyframes toastShake { + 0%, 100% { transform: translateX(-50%) translateY(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-50%) translateY(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(-50%) translateY(5px); } } .toast.success { @@ -802,6 +886,15 @@ input:-webkit-autofill:focus { animation: slideUp 0.3s ease-out; } +#template-modal .modal-content { + max-width: 500px !important; + width: 100% !important; +} + +#test-template-modal .modal-content { + max-width: 420px; +} + @keyframes slideUp { from { transform: translateY(20px); @@ -1608,3 +1701,257 @@ input:-webkit-autofill:focus { /* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */ +/* =========================== + Capture Templates Styles + =========================== */ + +.templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; +} + +.template-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + transition: box-shadow 0.2s; + display: flex; + flex-direction: column; +} + +.template-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.add-template-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 180px; + cursor: pointer; + border: 2px dashed var(--border-color); + background: transparent; + transition: border-color 0.2s, background 0.2s; +} + +.add-template-card:hover { + border-color: var(--primary-color); + background: rgba(33, 150, 243, 0.05); + box-shadow: none; +} + +.add-template-icon { + font-size: 2.5rem; + font-weight: 300; + color: var(--text-secondary); + line-height: 1; + transition: color 0.2s; +} + +.add-template-card:hover .add-template-icon { + color: var(--primary-color); +} + +.add-template-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 8px; + font-weight: 500; +} + +.template-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.template-name { + font-size: 18px; + font-weight: bold; + color: var(--text-color); +} + +.badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; +} + +.badge-default { + background: var(--primary-color); + color: white; +} + +.template-description { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 12px; + line-height: 1.4; +} + +.template-config { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.template-config-details { + margin: 12px 0; + font-size: 13px; +} + +.template-config-details summary { + cursor: pointer; + color: var(--primary-color); + font-weight: 500; + padding: 4px 0; +} + +.template-config-details summary:hover { + text-decoration: underline; +} + +.template-no-config { + margin: 12px 0; + font-size: 13px; + color: var(--primary-color); + font-weight: 500; + padding: 4px 0; +} + +.template-config-details pre { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 12px; + margin-top: 8px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; +} + +.template-card-actions { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--border-color); + align-items: center; +} + +.template-card-actions .btn:not(.btn-icon) { + flex: 1; +} + +.template-card-actions .btn-icon { + flex-shrink: 0; +} + +.text-muted { + color: var(--text-secondary); + font-style: italic; + font-size: 13px; +} + +/* Template Test Section */ +.template-test-section { + background: var(--bg-secondary); + border-radius: 8px; + padding: 16px; +} + +.template-test-section h3 { + margin-top: 0; + margin-bottom: 12px; + font-size: 16px; +} + +.test-results-container { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; +} + +.test-preview-section, +.test-performance-section { + margin-bottom: 20px; +} + +.test-preview-section:last-child, +.test-performance-section:last-child { + margin-bottom: 0; +} + +.test-preview-section h4, +.test-performance-section h4 { + margin-top: 0; + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); +} + +.test-preview-image { + border-radius: 4px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.test-preview-image img { + display: block; + width: 100%; + height: auto; +} + +.test-performance-stats { + display: flex; + flex-direction: column; + gap: 8px; +} + +.stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); + font-size: 14px; +} + +.stat-item:last-child { + border-bottom: none; +} + +.stat-item span { + color: var(--text-secondary); + font-weight: 500; +} + +.stat-item strong { + color: var(--text-color); + font-weight: 600; + font-family: monospace; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + font-size: 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .templates-grid { + grid-template-columns: 1fr; + } +} +