diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 13722fb..d8d3180 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -611,6 +611,7 @@ function createDeviceCard(device) { ` : ''} + `; } @@ -933,7 +934,12 @@ async function handleAddDevice(event) { console.log('Device added successfully:', result); showToast('Device added successfully', 'success'); closeAddDeviceModal(); - loadDevices(); + await loadDevices(); + // Auto-start device tutorial on first device add + if (!localStorage.getItem('deviceTutorialSeen')) { + localStorage.setItem('deviceTutorialSeen', '1'); + setTimeout(() => startDeviceTutorial(), 300); + } } else { const errorData = await response.json(); console.error('Failed to add device:', errorData); @@ -1090,7 +1096,14 @@ async function showCalibration(deviceId) { // Initialize span drag and render canvas after layout settles initSpanDrag(); - requestAnimationFrame(() => renderCalibrationCanvas()); + requestAnimationFrame(() => { + renderCalibrationCanvas(); + // Auto-start tutorial on first open + if (!localStorage.getItem('calibrationTutorialSeen')) { + localStorage.setItem('calibrationTutorialSeen', '1'); + startCalibrationTutorial(); + } + }); // Re-render on container resize (e.g. window resize changes aspect-ratio container) if (!window._calibrationResizeObserver) { @@ -1121,6 +1134,7 @@ function isCalibrationDirty() { } function forceCloseCalibrationModal() { + closeTutorial(); const deviceId = document.getElementById('calibration-device-id').value; if (deviceId) { clearTestMode(deviceId); @@ -1828,6 +1842,7 @@ document.addEventListener('mousedown', (e) => { document.addEventListener('click', (e) => { if (!e.target.classList.contains('modal')) return; if (backdropMouseDownTarget !== e.target) return; + if (activeTutorial) return; const modalId = e.target.id; @@ -1871,3 +1886,229 @@ window.addEventListener('beforeunload', () => { clearInterval(refreshInterval); } }); + +// ============================================================================= +// Tutorial System (generic engine) +// ============================================================================= + +let activeTutorial = null; +// Shape: { steps, overlay, mode, step, resolveTarget, container } +// mode: 'absolute' (within a container) or 'fixed' (viewport-level) + +const calibrationTutorialSteps = [ + { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, + { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, + { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, + { selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' }, + { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, + { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, + { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'bottom' } +]; + +const deviceTutorialSteps = [ + { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, + { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, + { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, + { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, + { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' }, + { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' } +]; + +function startTutorial(config) { + closeTutorial(); + const overlay = document.getElementById(config.overlayId); + if (!overlay) return; + + activeTutorial = { + steps: config.steps, + overlay: overlay, + mode: config.mode, + step: 0, + resolveTarget: config.resolveTarget, + container: config.container + }; + + overlay.classList.add('active'); + document.addEventListener('keydown', handleTutorialKey); + showTutorialStep(0); +} + +function startCalibrationTutorial() { + const container = document.querySelector('#calibration-modal .modal-body'); + if (!container) return; + startTutorial({ + steps: calibrationTutorialSteps, + overlayId: 'tutorial-overlay', + mode: 'absolute', + container: container, + resolveTarget: (step) => document.querySelector(step.selector) + }); +} + +function startDeviceTutorial(deviceId) { + // Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM) + const selector = deviceId + ? `.card[data-device-id="${deviceId}"]` + : '.card[data-device-id]'; + if (!document.querySelector(selector)) return; + startTutorial({ + steps: deviceTutorialSteps, + overlayId: 'device-tutorial-overlay', + mode: 'fixed', + container: null, + resolveTarget: (step) => { + const card = document.querySelector(selector); + if (!card) return null; + return step.global + ? document.querySelector(step.selector) + : card.querySelector(step.selector); + } + }); +} + +function closeTutorial() { + if (!activeTutorial) return; + activeTutorial.overlay.classList.remove('active'); + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + document.removeEventListener('keydown', handleTutorialKey); + activeTutorial = null; +} + +function tutorialNext() { + if (!activeTutorial) return; + if (activeTutorial.step < activeTutorial.steps.length - 1) { + showTutorialStep(activeTutorial.step + 1); + } else { + closeTutorial(); + } +} + +function tutorialPrev() { + if (!activeTutorial) return; + if (activeTutorial.step > 0) { + showTutorialStep(activeTutorial.step - 1); + } +} + +function showTutorialStep(index) { + if (!activeTutorial) return; + activeTutorial.step = index; + const step = activeTutorial.steps[index]; + const overlay = activeTutorial.overlay; + const isFixed = activeTutorial.mode === 'fixed'; + + // Remove previous target highlight + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + + // Find and highlight target + const target = activeTutorial.resolveTarget(step); + if (!target) return; + target.classList.add('tutorial-target'); + // For fixed overlays, target must be above the z-index:10000 overlay + if (isFixed) target.style.zIndex = '10001'; + + const targetRect = target.getBoundingClientRect(); + const pad = 6; + let x, y, w, h; + + if (isFixed) { + // Fixed mode: coordinates are viewport-relative + x = targetRect.left - pad; + y = targetRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } else { + // Absolute mode: coordinates relative to container + const containerRect = activeTutorial.container.getBoundingClientRect(); + x = targetRect.left - containerRect.left - pad; + y = targetRect.top - containerRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } + + // Update backdrop clip-path (polygon with rectangular cutout) + const backdrop = overlay.querySelector('.tutorial-backdrop'); + if (backdrop) { + backdrop.style.clipPath = `polygon( + 0% 0%, 0% 100%, + ${x}px 100%, ${x}px ${y}px, + ${x + w}px ${y}px, ${x + w}px ${y + h}px, + ${x}px ${y + h}px, ${x}px 100%, + 100% 100%, 100% 0%)`; + } + + // Position ring around target + const ring = overlay.querySelector('.tutorial-ring'); + if (ring) { + ring.style.left = x + 'px'; + ring.style.top = y + 'px'; + ring.style.width = w + 'px'; + ring.style.height = h + 'px'; + } + + // Update tooltip content + const tooltip = overlay.querySelector('.tutorial-tooltip'); + const textEl = overlay.querySelector('.tutorial-tooltip-text'); + const counterEl = overlay.querySelector('.tutorial-step-counter'); + if (textEl) textEl.textContent = t(step.textKey); + if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`; + + // Enable/disable nav buttons + const prevBtn = overlay.querySelector('.tutorial-prev-btn'); + const nextBtn = overlay.querySelector('.tutorial-next-btn'); + if (prevBtn) prevBtn.disabled = (index === 0); + if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192'; + + // Position tooltip + if (tooltip) { + positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); + } +} + +function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { + const gap = 12; + const tooltipW = 260; + // Place offscreen to measure real height without visual flash + tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); + const tooltipH = tooltip.offsetHeight || 150; + + const positions = { + top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap }, + bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap }, + left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 }, + right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } + }; + + let pos = positions[preferred] || positions.bottom; + + const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth; + const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight; + + // If preferred position overflows, try opposite + if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { + const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + const alt = positions[opposite[preferred]]; + if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { + pos = alt; + } + } + + pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x)); + pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); + + // Force-set all positioning via setAttribute to avoid any style-setting quirks + tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); +} + +function handleTutorialKey(e) { + if (!activeTutorial) return; + if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 8b2a919..ac06a11 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -80,17 +80,11 @@