Compare commits
2 Commits
cf019318a6
...
2a085e63a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a085e63a0 | |||
| fa322ee0ce |
@@ -611,6 +611,7 @@ function createDeviceCard(device) {
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1297,23 +1311,28 @@ function renderCalibrationCanvas() {
|
||||
const count = seg.led_count;
|
||||
if (count === 0) return;
|
||||
|
||||
// Mandatory ticks: first and last LED index per edge, plus LED 0 if offset > 0
|
||||
const labelsToShow = new Set();
|
||||
labelsToShow.add(0);
|
||||
if (count > 1) labelsToShow.add(count - 1);
|
||||
// Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
|
||||
const edgeBounds = new Set();
|
||||
edgeBounds.add(0);
|
||||
if (count > 1) edgeBounds.add(count - 1);
|
||||
|
||||
const specialTicks = new Set();
|
||||
if (offset > 0 && totalLeds > 0) {
|
||||
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
|
||||
if (zeroPos < count) labelsToShow.add(zeroPos);
|
||||
if (zeroPos < count) specialTicks.add(zeroPos);
|
||||
}
|
||||
|
||||
// Add intermediate ticks at "nice" intervals (max 5 labels per edge)
|
||||
// Round-number ticks get priority; edge boundary labels suppressed if overlapping
|
||||
const labelsToShow = new Set([...specialTicks]);
|
||||
const tickLinesOnly = new Set();
|
||||
|
||||
if (count > 2) {
|
||||
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
|
||||
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
|
||||
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
|
||||
|
||||
const maxIntermediate = Math.max(0, 5 - labelsToShow.size);
|
||||
const allMandatory = new Set([...edgeBounds, ...specialTicks]);
|
||||
const maxIntermediate = Math.max(0, 5 - allMandatory.size);
|
||||
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
|
||||
let step = niceSteps[niceSteps.length - 1];
|
||||
for (const s of niceSteps) {
|
||||
@@ -1323,18 +1342,17 @@ function renderCalibrationCanvas() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pixel position helper (0..edgeLen along the edge)
|
||||
const tickPx = i => {
|
||||
const f = i / (count - 1);
|
||||
return (seg.reverse ? (1 - f) : f) * edgeLen;
|
||||
};
|
||||
|
||||
// Collect pixel positions of mandatory ticks
|
||||
// Phase 1: place round-number ticks (checked against specials + each other)
|
||||
const placed = [];
|
||||
labelsToShow.forEach(i => placed.push(tickPx(i)));
|
||||
specialTicks.forEach(i => placed.push(tickPx(i)));
|
||||
|
||||
// Add ticks at LED indices divisible by step
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
if (specialTicks.has(i)) continue;
|
||||
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||||
if (idx % step === 0) {
|
||||
const px = tickPx(i);
|
||||
@@ -1344,9 +1362,23 @@ function renderCalibrationCanvas() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: edge boundaries — show label unless overlapping a round-number tick
|
||||
edgeBounds.forEach(bi => {
|
||||
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
|
||||
const px = tickPx(bi);
|
||||
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
|
||||
tickLinesOnly.add(bi);
|
||||
} else {
|
||||
labelsToShow.add(bi);
|
||||
placed.push(px);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
edgeBounds.forEach(i => labelsToShow.add(i));
|
||||
}
|
||||
|
||||
// Tick styling — min/max ticks extend to container border, others short
|
||||
// Tick styling
|
||||
const tickLenLong = toggleSize + 3;
|
||||
const tickLenShort = 4;
|
||||
ctx.strokeStyle = tickStroke;
|
||||
@@ -1354,45 +1386,66 @@ function renderCalibrationCanvas() {
|
||||
ctx.fillStyle = tickFill;
|
||||
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
|
||||
// Draw labeled ticks
|
||||
labelsToShow.forEach(i => {
|
||||
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
||||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||||
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||||
const tickLen = (i === 0 || i === count - 1) ? tickLenLong : tickLenShort;
|
||||
const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
|
||||
|
||||
if (geo.horizontal) {
|
||||
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||||
const axisY = axisPos[seg.edge];
|
||||
const tickDir = seg.edge === 'top' ? 1 : -1; // tick toward container
|
||||
const tickDir = seg.edge === 'top' ? 1 : -1;
|
||||
|
||||
// Tick line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, axisY);
|
||||
ctx.lineTo(tx, axisY + tickDir * tickLen);
|
||||
ctx.stroke();
|
||||
|
||||
// Label outside
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
|
||||
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
|
||||
} else {
|
||||
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||||
const axisX = axisPos[seg.edge];
|
||||
const tickDir = seg.edge === 'left' ? 1 : -1; // tick toward container
|
||||
const tickDir = seg.edge === 'left' ? 1 : -1;
|
||||
|
||||
// Tick line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(axisX, ty);
|
||||
ctx.lineTo(axisX + tickDir * tickLen, ty);
|
||||
ctx.stroke();
|
||||
|
||||
// Label outside
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
|
||||
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw tick lines only (no labels) for suppressed edge boundaries
|
||||
tickLinesOnly.forEach(i => {
|
||||
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
||||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||||
|
||||
if (geo.horizontal) {
|
||||
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||||
const axisY = axisPos[seg.edge];
|
||||
const tickDir = seg.edge === 'top' ? 1 : -1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, axisY);
|
||||
ctx.lineTo(tx, axisY + tickDir * tickLenLong);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||||
const axisX = axisPos[seg.edge];
|
||||
const tickDir = seg.edge === 'left' ? 1 : -1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(axisX, ty);
|
||||
ctx.lineTo(axisX + tickDir * tickLenLong, ty);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// Draw direction chevron at full-edge midpoint (not affected by span)
|
||||
const s = 7;
|
||||
let mx, my, angle;
|
||||
@@ -1789,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;
|
||||
|
||||
@@ -1832,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(); }
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="calibration.title">📐 LED Calibration</h2>
|
||||
<button class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button>
|
||||
<button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="calibration-device-id">
|
||||
<p class="section-tip" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p>
|
||||
<!-- Interactive Preview with integrated LED inputs and test toggles -->
|
||||
<div style="margin-bottom: 12px; padding: 0 24px;">
|
||||
<div class="calibration-preview">
|
||||
@@ -168,6 +168,23 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Tutorial Overlay -->
|
||||
<div id="tutorial-overlay" class="tutorial-overlay">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
<div class="tutorial-ring"></div>
|
||||
<div class="tutorial-tooltip">
|
||||
<div class="tutorial-tooltip-header">
|
||||
<span class="tutorial-step-counter"></span>
|
||||
<button class="tutorial-close-btn" onclick="closeTutorial()">×</button>
|
||||
</div>
|
||||
<p class="tutorial-tooltip-text"></p>
|
||||
<div class="tutorial-tooltip-nav">
|
||||
<button class="tutorial-prev-btn" onclick="tutorialPrev()">←</button>
|
||||
<button class="tutorial-next-btn" onclick="tutorialNext()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calibration-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -304,6 +321,23 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Device Tutorial Overlay (viewport-level) -->
|
||||
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
<div class="tutorial-ring"></div>
|
||||
<div class="tutorial-tooltip">
|
||||
<div class="tutorial-tooltip-header">
|
||||
<span class="tutorial-step-counter"></span>
|
||||
<button class="tutorial-close-btn" onclick="closeTutorial()">×</button>
|
||||
</div>
|
||||
<p class="tutorial-tooltip-text"></p>
|
||||
<div class="tutorial-tooltip-nav">
|
||||
<button class="tutorial-prev-btn" onclick="tutorialPrev()">←</button>
|
||||
<button class="tutorial-next-btn" onclick="tutorialNext()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script>
|
||||
// Initialize theme
|
||||
|
||||
@@ -77,6 +77,14 @@
|
||||
"device.health.online": "WLED Online",
|
||||
"device.health.offline": "WLED Offline",
|
||||
"device.health.checking": "Checking...",
|
||||
"device.tutorial.start": "Start tutorial",
|
||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
|
||||
"device.tip.brightness": "Slide to adjust device brightness",
|
||||
"device.tip.start": "Start or stop screen capture processing",
|
||||
"device.tip.settings": "Configure device settings (display, FPS, health check)",
|
||||
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
|
||||
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
|
||||
"device.tip.add": "Click here to add a new WLED device",
|
||||
"settings.title": "Device Settings",
|
||||
"settings.brightness": "Brightness:",
|
||||
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
||||
@@ -90,7 +98,14 @@
|
||||
"settings.saved": "Settings saved successfully",
|
||||
"settings.failed": "Failed to save settings",
|
||||
"calibration.title": "LED Calibration",
|
||||
"calibration.preview.click_hint": "Click an edge to toggle test LEDs on/off",
|
||||
"calibration.tip.led_count": "Enter LED count per edge",
|
||||
"calibration.tip.start_corner": "Click a corner to set the start position",
|
||||
"calibration.tip.direction": "Toggle LED strip direction (clockwise / counterclockwise)",
|
||||
"calibration.tip.offset": "Set LED offset — distance from LED 0 to the start corner",
|
||||
"calibration.tip.span": "Drag green bars to adjust coverage span",
|
||||
"calibration.tip.test": "Click an edge to toggle test LEDs",
|
||||
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
|
||||
"calibration.tutorial.start": "Start tutorial",
|
||||
"calibration.start_position": "Starting Position:",
|
||||
"calibration.position.bottom_left": "Bottom Left",
|
||||
"calibration.position.bottom_right": "Bottom Right",
|
||||
|
||||
@@ -77,6 +77,14 @@
|
||||
"device.health.online": "WLED Онлайн",
|
||||
"device.health.offline": "WLED Недоступен",
|
||||
"device.health.checking": "Проверка...",
|
||||
"device.tutorial.start": "Начать обучение",
|
||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
|
||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||
"device.tip.start": "Запуск или остановка захвата экрана",
|
||||
"device.tip.settings": "Настройки устройства (дисплей, FPS, интервал проверки)",
|
||||
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
|
||||
"settings.title": "Настройки Устройства",
|
||||
"settings.brightness": "Яркость:",
|
||||
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
||||
@@ -90,7 +98,14 @@
|
||||
"settings.saved": "Настройки успешно сохранены",
|
||||
"settings.failed": "Не удалось сохранить настройки",
|
||||
"calibration.title": "Калибровка Светодиодов",
|
||||
"calibration.preview.click_hint": "Нажмите на край чтобы включить/выключить тест светодиодов",
|
||||
"calibration.tip.led_count": "Укажите количество LED на каждой стороне",
|
||||
"calibration.tip.start_corner": "Нажмите на угол для выбора стартовой позиции",
|
||||
"calibration.tip.direction": "Переключение направления ленты (по часовой / против часовой)",
|
||||
"calibration.tip.offset": "Смещение LED — расстояние от LED 0 до стартового угла",
|
||||
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
|
||||
"calibration.tip.test": "Нажмите на край для теста LED",
|
||||
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
|
||||
"calibration.tutorial.start": "Начать обучение",
|
||||
"calibration.start_position": "Начальная Позиция:",
|
||||
"calibration.position.bottom_left": "Нижний Левый",
|
||||
"calibration.position.bottom_right": "Нижний Правый",
|
||||
|
||||
@@ -206,7 +206,7 @@ section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
padding: 12px 20px 20px;
|
||||
position: relative;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
@@ -215,6 +215,31 @@ section {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-tutorial-btn {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color);
|
||||
color: var(--text-muted, #777);
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-tutorial-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-remove-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -242,7 +267,7 @@ section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
@@ -629,6 +654,15 @@ section {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul.section-tip {
|
||||
list-style: disc;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
ul.section-tip li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@@ -1359,3 +1393,202 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* Tutorial System */
|
||||
.tutorial-trigger-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-color);
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tutorial-trigger-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#calibration-modal .modal-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tutorial-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tutorial-overlay.active {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tutorial-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
transition: clip-path 0.3s ease;
|
||||
}
|
||||
|
||||
.tutorial-ring {
|
||||
position: absolute;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
animation: tutorial-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes tutorial-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.6); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(76, 175, 80, 0); }
|
||||
}
|
||||
|
||||
.tutorial-tooltip {
|
||||
position: absolute;
|
||||
width: 260px;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
z-index: 102;
|
||||
pointer-events: auto;
|
||||
animation: tutorial-tooltip-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tutorial-tooltip-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tutorial-tooltip-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tutorial-step-counter {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tutorial-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #777;
|
||||
font-size: 1.3rem;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tutorial-close-btn:hover {
|
||||
color: var(--text-color);
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.tutorial-tooltip-text {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tutorial-prev-btn,
|
||||
.tutorial-next-btn {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tutorial-prev-btn {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tutorial-next-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tutorial-prev-btn:hover:not(:disabled),
|
||||
.tutorial-next-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tutorial-prev-btn:disabled,
|
||||
.tutorial-next-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tutorial-target {
|
||||
position: relative;
|
||||
z-index: 101 !important;
|
||||
}
|
||||
|
||||
/* Fixed (viewport-level) tutorial overlay for device cards */
|
||||
.tutorial-overlay-fixed {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.tutorial-overlay-fixed .tutorial-backdrop {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.tutorial-overlay-fixed .tutorial-ring {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.tutorial-overlay-fixed .tutorial-tooltip {
|
||||
position: absolute;
|
||||
z-index: 10002;
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user