feat(onboarding): guided first-run setup wizard (phase 4, final)
A multi-step first-run wizard that takes a brand-new user from install to a running, calibrated ambient light in ~2 minutes, orchestrating the existing primitives (no node graph required). - features/setup-wizard.ts + modals/setup-wizard.html: welcome -> find device (discovery list + manual add via the canonical, URL-validated POST /devices) -> pick screen (GET /config/displays) -> scaffold (POST /setup/scaffold) -> calibrate (embeds the phase-3 auto-calibration flow via mountAutoCalibration on the scaffolded CSS + device) -> start -> done, with a progress indicator. - First-run trigger in app.ts (checkAndOpenWizardIfNeeded): on load, if the onboarding flag is unset AND no output targets exist, the wizard takes over and the tooltip tour is suppressed; on finish/skip it PUTs the onboarding flag and sets localStorage tour_completed so neither re-fires. Re-runnable. - tutorials.ts exposes TOUR_KEY + a takeover hook so the getting-started tour and the wizard never double-fire. - Calibrate step always calls unmountAutoCalibration() on exit so the device is restored. i18n in en/ru/zh (wizard.* keys + common.back). Final phase of the edge-calibration + first-run-wizard feature. Big Bang final gate green: tsc --noEmit clean, npm run build passes, full pytest suite 2064 passed / 2 skipped, ruff clean.
This commit is contained in:
@@ -2651,3 +2651,371 @@ textarea:focus-visible {
|
|||||||
.autocal-direction-btn { transition: none; }
|
.autocal-direction-btn { transition: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Setup Wizard (features/setup-wizard.ts)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.wizard-progress-bar {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.wizard-progress-track {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wizard-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pip indicators */
|
||||||
|
.wizard-progress-labels {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.wizard-pip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
|
||||||
|
color: var(--text-muted, var(--secondary-text-color));
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.wizard-pip--done {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.wizard-pip--done .icon { width: 12px; height: 12px; }
|
||||||
|
.wizard-pip--active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step layout */
|
||||||
|
.wizard-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wizard-step-icon .icon { width: 18px; height: 18px; }
|
||||||
|
.wizard-step-icon--ok {
|
||||||
|
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
|
||||||
|
color: var(--success-color, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.wizard-step-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, var(--secondary-text-color));
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome step */
|
||||||
|
.wizard-step--welcome {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.wizard-welcome-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.wizard-welcome-icon .icon { width: 32px; height: 32px; }
|
||||||
|
.wizard-welcome-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.wizard-welcome-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
}
|
||||||
|
.wizard-welcome-list li .icon { width: 16px; height: 16px; color: var(--primary-color); flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Discovery section */
|
||||||
|
.wizard-discovery-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.wizard-section-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, var(--secondary-text-color));
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.wizard-section-label--scan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.wizard-scan-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.wizard-scan-btn:hover { background: color-mix(in srgb, var(--primary-color) 10%, transparent); }
|
||||||
|
.wizard-scan-btn .icon { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
.wizard-discovery-scanning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, var(--secondary-text-color));
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
}
|
||||||
|
.wizard-discovery-empty {
|
||||||
|
padding: 14px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, var(--secondary-text-color));
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
}
|
||||||
|
.wizard-discovery-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.wizard-discovery-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.wizard-discovery-item:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
|
||||||
|
}
|
||||||
|
.wizard-discovery-icon { color: var(--primary-color); flex-shrink: 0; }
|
||||||
|
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
|
||||||
|
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wizard-discovery-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display list */
|
||||||
|
.wizard-display-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.wizard-display-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.wizard-display-item:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
|
||||||
|
}
|
||||||
|
.wizard-display-item--active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
|
||||||
|
}
|
||||||
|
.wizard-display-icon { color: var(--primary-color); flex-shrink: 0; }
|
||||||
|
.wizard-display-icon .icon { width: 20px; height: 20px; }
|
||||||
|
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||||
|
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
|
||||||
|
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
|
||||||
|
.wizard-display-check { color: var(--primary-color); }
|
||||||
|
.wizard-display-check .icon { width: 16px; height: 16px; }
|
||||||
|
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* Scaffold / start progress */
|
||||||
|
.wizard-scaffold-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
}
|
||||||
|
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
|
||||||
|
|
||||||
|
/* Calibrate container */
|
||||||
|
.wizard-calibrate-container {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done step */
|
||||||
|
.wizard-step--done {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.wizard-done-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
|
||||||
|
color: var(--success-color, #4caf50);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.wizard-done-icon .icon { width: 32px; height: 32px; }
|
||||||
|
.wizard-done-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
|
||||||
|
.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
|
||||||
|
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
|
||||||
|
|
||||||
|
/* Wizard form rows */
|
||||||
|
.wizard-form-row { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.wizard-form-label { font-size: 0.82rem; font-weight: 600; color: var(--text-color); }
|
||||||
|
|
||||||
|
/* Error banner */
|
||||||
|
.wizard-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger-color, #f44336) 30%, transparent);
|
||||||
|
color: var(--danger-color, #f44336);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.wizard-error .icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|
||||||
|
/* Footer (nav buttons) */
|
||||||
|
.wizard-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wizard-footer > .btn:first-child { margin-right: auto; }
|
||||||
|
.wizard-footer--done { justify-content: center; border-top: none; padding-top: 0; }
|
||||||
|
.wizard-footer--done > .btn:first-child { margin-right: 0; }
|
||||||
|
|
||||||
|
/* Btn spinner (inline in disabled state) */
|
||||||
|
.btn-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Toolbar wizard re-run button */
|
||||||
|
.header-btn[id="wizard-rerun-btn"] { }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.wizard-progress-fill,
|
||||||
|
.wizard-pip,
|
||||||
|
.wizard-discovery-item,
|
||||||
|
.wizard-display-item { transition: none; }
|
||||||
|
.btn-spinner { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ import {
|
|||||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||||
startIntegrationsTutorial,
|
startIntegrationsTutorial,
|
||||||
closeTutorial, tutorialNext, tutorialPrev,
|
closeTutorial, tutorialNext, tutorialPrev,
|
||||||
|
TOUR_KEY,
|
||||||
} from './features/tutorials.ts';
|
} from './features/tutorials.ts';
|
||||||
|
import {
|
||||||
|
openSetupWizard, closeSetupWizard,
|
||||||
|
checkAndOpenWizardIfNeeded,
|
||||||
|
wizardNext, wizardBack, wizardSkip, wizardFinish,
|
||||||
|
wizardShowManual, wizardHideManual, wizardRescan,
|
||||||
|
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
} from './features/setup-wizard.ts';
|
||||||
|
|
||||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||||
import {
|
import {
|
||||||
@@ -329,6 +338,21 @@ Object.assign(window, {
|
|||||||
selectDisplay,
|
selectDisplay,
|
||||||
formatDisplayLabel,
|
formatDisplayLabel,
|
||||||
|
|
||||||
|
// setup wizard
|
||||||
|
openSetupWizard,
|
||||||
|
closeSetupWizard,
|
||||||
|
wizardNext,
|
||||||
|
wizardBack,
|
||||||
|
wizardSkip,
|
||||||
|
wizardFinish,
|
||||||
|
wizardShowManual,
|
||||||
|
wizardHideManual,
|
||||||
|
wizardRescan,
|
||||||
|
wizardSelectDiscovered,
|
||||||
|
wizardAddManualDevice,
|
||||||
|
wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
|
||||||
// tutorials
|
// tutorials
|
||||||
startCalibrationTutorial,
|
startCalibrationTutorial,
|
||||||
startDeviceTutorial,
|
startDeviceTutorial,
|
||||||
@@ -951,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
||||||
initDonationBanner();
|
initDonationBanner();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// First-run: wizard wins over the tooltip tour.
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
//
|
||||||
|
// Precedence (explicit):
|
||||||
|
// 1. If backend says onboarded=false AND no output targets exist
|
||||||
|
// → open the setup wizard (suppresses tooltip tour — wizard owns
|
||||||
|
// the first-run experience; it sets localStorage TOUR_KEY on
|
||||||
|
// completion/skip so the tour never double-fires on reload).
|
||||||
|
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
|
||||||
|
// → fall back to the existing tooltip tour logic unchanged.
|
||||||
|
const wizardOpened = await checkAndOpenWizardIfNeeded();
|
||||||
|
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,811 @@
|
|||||||
|
/**
|
||||||
|
* Setup Wizard — multi-step first-run flow.
|
||||||
|
*
|
||||||
|
* Guides a brand-new user from zero to a running, calibrated LED strip in
|
||||||
|
* roughly seven steps:
|
||||||
|
* 1. Welcome
|
||||||
|
* 2. Find device — discovery scan + manual add fallback
|
||||||
|
* 3. Pick screen — GET /api/v1/config/displays
|
||||||
|
* 4. Scaffold — POST /api/v1/setup/scaffold → entity ids
|
||||||
|
* 5. Calibrate — embed mountAutoCalibration (Phase 3 component)
|
||||||
|
* 6. Start output — POST /api/v1/output-targets/{id}/start
|
||||||
|
* 7. Done
|
||||||
|
*
|
||||||
|
* First-run precedence (explicit):
|
||||||
|
* - app.ts checks GET /preferences/onboarding
|
||||||
|
* - if onboarded=false AND no output targets → open wizard, suppress tour
|
||||||
|
* - wizard completion/skip → PUT /preferences/onboarding {onboarded:true}
|
||||||
|
* + localStorage 'tour_completed' = '1' so the tour never double-fires
|
||||||
|
* - if onboarded=true → existing tour logic runs unchanged
|
||||||
|
*
|
||||||
|
* Re-entrant: openSetupWizard() is exported so a toolbar button can reopen it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { devicesCache, outputTargetsCache, displaysCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { mountAutoCalibration, unmountAutoCalibration } from './auto-calibration.ts';
|
||||||
|
import {
|
||||||
|
ICON_MONITOR, ICON_SPARKLES, ICON_DEVICE, ICON_OK, ICON_CHECK, ICON_ROCKET_ICON,
|
||||||
|
ICON_CALIBRATION, ICON_START, ICON_SEARCH, ICON_PLUS,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
import { getDeviceTypeIcon } from '../core/icons.ts';
|
||||||
|
import type { Device } from '../types.ts';
|
||||||
|
import type { Display } from '../types.ts';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WizardStep = 'welcome' | 'device' | 'display' | 'scaffold' | 'calibrate' | 'start' | 'done';
|
||||||
|
|
||||||
|
interface DiscoveredDevice {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
device_type: string;
|
||||||
|
led_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScaffoldResult {
|
||||||
|
device_id: string;
|
||||||
|
capture_template_id: string;
|
||||||
|
picture_source_id: string;
|
||||||
|
color_strip_source_id: string;
|
||||||
|
output_target_id: string;
|
||||||
|
capture_template_reused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WizardState {
|
||||||
|
step: WizardStep;
|
||||||
|
/** Persisted device id after creation. */
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
displayIndex: number;
|
||||||
|
displayName: string;
|
||||||
|
scaffoldResult: ScaffoldResult | null;
|
||||||
|
/** Populated by step 2 discovery scan. */
|
||||||
|
discoveredDevices: DiscoveredDevice[];
|
||||||
|
/** Manual-entry mode in step 2. */
|
||||||
|
manualMode: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module singleton ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: WizardState | null = null;
|
||||||
|
let _modal: SetupWizardModal | null = null;
|
||||||
|
|
||||||
|
const ONBOARDED_KEY = 'tour_completed'; // mirror tutorials.ts TOUR_KEY
|
||||||
|
|
||||||
|
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
|
||||||
|
|
||||||
|
// ── Modal class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SetupWizardModal extends Modal {
|
||||||
|
constructor() {
|
||||||
|
super('setup-wizard-modal');
|
||||||
|
}
|
||||||
|
onForceClose(): void {
|
||||||
|
_handleWizardClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Open the wizard (first-run or on-demand). */
|
||||||
|
export function openSetupWizard(): void {
|
||||||
|
if (!_modal) _modal = new SetupWizardModal();
|
||||||
|
_state = {
|
||||||
|
step: 'welcome',
|
||||||
|
deviceId: '',
|
||||||
|
deviceName: '',
|
||||||
|
displayIndex: 0,
|
||||||
|
displayName: '',
|
||||||
|
scaffoldResult: null,
|
||||||
|
discoveredDevices: [],
|
||||||
|
manualMode: false,
|
||||||
|
busy: false,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
_modal.open();
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the wizard and mark as complete / skipped. */
|
||||||
|
export function closeSetupWizard(): void {
|
||||||
|
if (!_modal) return;
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_modal.forceClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// First-run check (called from app.ts after auth passes)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check onboarding state and open the wizard on true first run.
|
||||||
|
*
|
||||||
|
* Returns `true` if the wizard was opened (caller should suppress the tour).
|
||||||
|
* Returns `false` if already onboarded (caller should proceed with tour logic).
|
||||||
|
*/
|
||||||
|
export async function checkAndOpenWizardIfNeeded(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [onboardingResp, targetsResp] = await Promise.all([
|
||||||
|
apiGet<{ onboarded: boolean; completed_at: string | null }>('/preferences/onboarding'),
|
||||||
|
outputTargetsCache.fetch().catch((): unknown[] => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (onboardingResp.onboarded) {
|
||||||
|
// Already onboarded — let tour run normally
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = Array.isArray(targetsResp) ? targetsResp : [];
|
||||||
|
if (targets.length > 0) {
|
||||||
|
// Has output targets but never completed onboarding wizard.
|
||||||
|
// Power user or migrated setup — mark done and skip wizard.
|
||||||
|
await _markOnboarded();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True first run: no targets, not onboarded
|
||||||
|
openSetupWizard();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// If the check itself fails (server offline, 404 on new backend, etc.)
|
||||||
|
// fall through to existing tour logic — don't block the UI.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Onboarding flag helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _markOnboarded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPut('/preferences/onboarding', { onboarded: true });
|
||||||
|
// Suppress tooltip tour too — wizard owns the first-run experience
|
||||||
|
localStorage.setItem(ONBOARDED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: UI already moved on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wizard step navigation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _stepIndex(step: WizardStep): number {
|
||||||
|
return STEPS.indexOf(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wizardNext(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const step = _state.step;
|
||||||
|
|
||||||
|
if (step === 'welcome') {
|
||||||
|
_state.step = 'device';
|
||||||
|
_renderStep();
|
||||||
|
_startDiscovery();
|
||||||
|
} else if (step === 'device') {
|
||||||
|
if (!_state.deviceId) {
|
||||||
|
_setError(t('wizard.error.no_device'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state.step = 'display';
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} else if (step === 'display') {
|
||||||
|
_state.step = 'scaffold';
|
||||||
|
_renderStep();
|
||||||
|
await _runScaffold();
|
||||||
|
} else if (step === 'calibrate') {
|
||||||
|
// "Skip calibration" path — move to start
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
await _startOutput();
|
||||||
|
} else if (step === 'start') {
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} else if (step === 'done') {
|
||||||
|
void closeSetupWizard();
|
||||||
|
await _markOnboarded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardBack(): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const idx = _stepIndex(_state.step);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
// Back from calibrate: unmount the autocal component
|
||||||
|
if (_state.step === 'calibrate') {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
}
|
||||||
|
_state.step = STEPS[idx - 1];
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSkip(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: device discovery
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startDiscovery(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const data = await apiGet<{ devices?: DiscoveredDevice[] }>('/devices/discover?timeout=3&device_type=wled');
|
||||||
|
_state.discoveredDevices = data.devices || [];
|
||||||
|
} catch {
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch device step to manual-entry mode. */
|
||||||
|
export function wizardShowManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardHideManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User clicked a discovered device — create it via POST /devices. */
|
||||||
|
export async function wizardSelectDiscovered(url: string, name: string, device_type: string): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
device_type,
|
||||||
|
url,
|
||||||
|
led_count: 60,
|
||||||
|
};
|
||||||
|
const device = await apiPost<Device>('/devices', body,
|
||||||
|
{ errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual device form submit. */
|
||||||
|
export async function wizardAddManualDevice(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const nameEl = document.getElementById('wizard-device-name') as HTMLInputElement | null;
|
||||||
|
const urlEl = document.getElementById('wizard-device-url') as HTMLInputElement | null;
|
||||||
|
const ledEl = document.getElementById('wizard-device-led-count') as HTMLInputElement | null;
|
||||||
|
const name = nameEl?.value.trim() || '';
|
||||||
|
const url = urlEl?.value.trim() || '';
|
||||||
|
const ledCount = parseInt(ledEl?.value || '60', 10) || 60;
|
||||||
|
|
||||||
|
if (!name) { _setError(t('wizard.error.device_name_required')); return; }
|
||||||
|
if (!url) { _setError(t('wizard.error.device_url_required')); return; }
|
||||||
|
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const device = await apiPost<Device>('/devices', {
|
||||||
|
name, url, device_type: 'wled', led_count: ledCount,
|
||||||
|
}, { errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User selected an already-existing device from the cache. */
|
||||||
|
export function wizardUseExistingDevice(deviceId: string, deviceName: string): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.deviceId = deviceId;
|
||||||
|
_state.deviceName = deviceName;
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
void _loadDisplays();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: display selection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _loadDisplays(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await displaysCache.fetch();
|
||||||
|
} catch {
|
||||||
|
// Fall through — render will show a fallback
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSelectDisplay(index: number, displayName: string): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.displayIndex = index;
|
||||||
|
_state.displayName = displayName;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: scaffold
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _runScaffold(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const result = await apiPost<ScaffoldResult>('/setup/scaffold', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
display_index: _state.displayIndex,
|
||||||
|
calibration: null,
|
||||||
|
}, { errorMessage: t('wizard.error.scaffold_failed') });
|
||||||
|
_state.scaffoldResult = result;
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'calibrate';
|
||||||
|
_renderStep();
|
||||||
|
// Mount the auto-calibration component inside the calibrate step container
|
||||||
|
const container = document.getElementById('wizard-calibrate-container');
|
||||||
|
if (container) {
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId: result.color_strip_source_id,
|
||||||
|
deviceId: _state.deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.scaffold_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: start output
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startOutput(): Promise<void> {
|
||||||
|
if (!_state?.scaffoldResult) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await apiPost<unknown>(`/output-targets/${_state.scaffoldResult.output_target_id}/start`, {},
|
||||||
|
{ errorMessage: t('wizard.error.start_failed') });
|
||||||
|
outputTargetsCache.invalidate();
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
// Non-fatal: still show done step but surface the error
|
||||||
|
showToast(err instanceof Error ? err.message : t('wizard.error.start_failed'), 'warning');
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.errorMsg = msg;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleWizardClose(): void {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rendering
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderStep(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const container = document.getElementById('wizard-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
_renderProgressBar();
|
||||||
|
|
||||||
|
const html = _buildStepHtml(_state);
|
||||||
|
container.innerHTML = html;
|
||||||
|
_attachStepListeners(_state.step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProgressBar(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const bar = document.getElementById('wizard-progress-bar');
|
||||||
|
const labels = document.getElementById('wizard-progress-labels');
|
||||||
|
if (!bar || !labels) return;
|
||||||
|
|
||||||
|
const currentIdx = _stepIndex(_state.step);
|
||||||
|
// Progress bar shows steps 1-6 (skip 'done' which is the finish state)
|
||||||
|
const visibleSteps: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start'];
|
||||||
|
const total = visibleSteps.length;
|
||||||
|
const activeIdx = visibleSteps.indexOf(_state.step);
|
||||||
|
const pct = activeIdx < 0 ? 100 : Math.round(((activeIdx) / (total - 1)) * 100);
|
||||||
|
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="wizard-progress-track">
|
||||||
|
<div class="wizard-progress-fill" style="width:${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stepLabels = visibleSteps.map((s, i) => {
|
||||||
|
const done = currentIdx > STEPS.indexOf(s);
|
||||||
|
const active = s === _state!.step;
|
||||||
|
const cls = done ? 'wizard-pip wizard-pip--done' : active ? 'wizard-pip wizard-pip--active' : 'wizard-pip';
|
||||||
|
return `<span class="${cls}" title="${t(`wizard.step.${s}`)}">${done ? ICON_CHECK : String(i + 1)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
labels.innerHTML = stepLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStepHtml(state: WizardState): string {
|
||||||
|
switch (state.step) {
|
||||||
|
case 'welcome': return _buildWelcomeStep();
|
||||||
|
case 'device': return _buildDeviceStep(state);
|
||||||
|
case 'display': return _buildDisplayStep(state);
|
||||||
|
case 'scaffold': return _buildScaffoldStep(state);
|
||||||
|
case 'calibrate':return _buildCalibrateStep(state);
|
||||||
|
case 'start': return _buildStartStep(state);
|
||||||
|
case 'done': return _buildDoneStep(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errorBanner(msg: string): string {
|
||||||
|
if (!msg) return '';
|
||||||
|
return `<div class="wizard-error">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||||
|
<span>${msg}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildWelcomeStep(): string {
|
||||||
|
return `<div class="wizard-step wizard-step--welcome">
|
||||||
|
<div class="wizard-welcome-icon">${ICON_SPARKLES}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.welcome.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.welcome.desc')}</p>
|
||||||
|
<ul class="wizard-welcome-list">
|
||||||
|
<li>${ICON_DEVICE}<span>${t('wizard.welcome.item1')}</span></li>
|
||||||
|
<li>${ICON_MONITOR}<span>${t('wizard.welcome.item2')}</span></li>
|
||||||
|
<li>${ICON_CALIBRATION}<span>${t('wizard.welcome.item3')}</span></li>
|
||||||
|
<li>${ICON_START}<span>${t('wizard.welcome.item4')}</span></li>
|
||||||
|
</ul>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()">${t('wizard.start')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDeviceStep(state: WizardState): string {
|
||||||
|
const existingDevices: Device[] = devicesCache.data || [];
|
||||||
|
|
||||||
|
let discoveryHtml = '';
|
||||||
|
if (state.busy && state.discoveredDevices.length === 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.device.scanning')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (state.discoveredDevices.length > 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-list">` +
|
||||||
|
state.discoveredDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardSelectDiscovered('${_esc(d.url)}','${_esc(d.name)}','${_esc(d.device_type)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
} else {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-empty">
|
||||||
|
<span>${t('wizard.device.none_found')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingHtml = '';
|
||||||
|
if (existingDevices.length > 0) {
|
||||||
|
existingHtml = `<div class="wizard-section-label">${t('wizard.device.existing')}</div>
|
||||||
|
<div class="wizard-discovery-list">` +
|
||||||
|
existingDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardUseExistingDevice('${_esc(d.id)}','${_esc(d.name)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manualHtml = '';
|
||||||
|
if (state.manualMode) {
|
||||||
|
manualHtml = `<form id="wizard-manual-form" onsubmit="wizardAddManualDevice(event)">
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.name')}</label>
|
||||||
|
<input id="wizard-device-name" class="form-input" type="text" placeholder="${t('wizard.device.manual.name_placeholder')}" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.url')}</label>
|
||||||
|
<input id="wizard-device-url" class="form-input" type="text" placeholder="http://192.168.1.x" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.led_count')}</label>
|
||||||
|
<input id="wizard-device-led-count" class="form-input" type="number" min="1" max="1000" value="60">
|
||||||
|
</div>
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="wizardHideManual()">${t('common.back')}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"${state.busy ? ' disabled' : ''}>
|
||||||
|
${state.busy ? `<div class="btn-spinner"></div>` : ''}${t('wizard.device.manual.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
} else {
|
||||||
|
manualHtml = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_DEVICE}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.device.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.device.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!state.manualMode ? `
|
||||||
|
<div class="wizard-discovery-section">
|
||||||
|
<div class="wizard-section-label wizard-section-label--scan">
|
||||||
|
${t('wizard.device.discovered')}
|
||||||
|
<button class="wizard-scan-btn" onclick="wizardRescan()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${ICON_SEARCH} ${t('wizard.device.rescan')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${discoveryHtml}
|
||||||
|
</div>
|
||||||
|
${existingHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-secondary" onclick="wizardShowManual()">
|
||||||
|
${ICON_PLUS} ${t('wizard.device.manual.title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : manualHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDisplayStep(state: WizardState): string {
|
||||||
|
const displays: Display[] = displaysCache.data ?? [];
|
||||||
|
|
||||||
|
let listHtml = '';
|
||||||
|
if (state.busy && displays.length === 0) {
|
||||||
|
listHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.display.loading')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (displays.length === 0) {
|
||||||
|
// Fallback: offer a manual index input
|
||||||
|
listHtml = `<div class="wizard-display-fallback">
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.no_displays')}</p>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
|
||||||
|
<input id="wizard-display-index-manual" class="form-input" type="number"
|
||||||
|
min="0" max="63" value="${state.displayIndex}"
|
||||||
|
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
listHtml = `<div class="wizard-display-list">` +
|
||||||
|
displays.map(d => {
|
||||||
|
const active = d.index === state.displayIndex;
|
||||||
|
return `<button class="wizard-display-item${active ? ' wizard-display-item--active' : ''}"
|
||||||
|
onclick="wizardSelectDisplay(${d.index}, '${_esc(d.name)}')">
|
||||||
|
<span class="wizard-display-icon">${ICON_MONITOR}</span>
|
||||||
|
<span class="wizard-display-details">
|
||||||
|
<span class="wizard-display-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-display-dims">${d.width} × ${d.height}${d.is_primary ? ' · ' + t('wizard.display.primary') : ''}</span>
|
||||||
|
</span>
|
||||||
|
${active ? `<span class="wizard-display-check">${ICON_CHECK}</span>` : ''}
|
||||||
|
</button>`;
|
||||||
|
}).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_MONITOR}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.display.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${listHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardBack()">${t('common.back')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${t('wizard.display.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildScaffoldStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--scaffold">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${state.scaffoldResult ? ' wizard-step-icon--ok' : ''}">${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.scaffold.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.scaffold.building')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCalibrateStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--calibrate">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_CALIBRATION}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.calibrate.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.calibrate.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- auto-calibration.ts mounts here -->
|
||||||
|
<div id="wizard-calibrate-container" class="wizard-calibrate-container"></div>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardNext()">${t('wizard.calibrate.skip')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStartStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--start">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${!state.busy && !state.errorMsg ? ' wizard-step-icon--ok' : ''}">${START_STEP_ICON(state)}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.start.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.start.starting')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function START_STEP_ICON(state: WizardState): string {
|
||||||
|
if (state.busy) return ICON_START;
|
||||||
|
if (state.errorMsg) return ICON_START;
|
||||||
|
return ICON_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDoneStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--done">
|
||||||
|
<div class="wizard-done-icon">${ICON_ROCKET_ICON}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.done.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.done.desc')}</p>
|
||||||
|
${state.scaffoldResult ? `<div class="wizard-done-summary">
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.device')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.deviceName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.display')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))}</span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="wizard-footer wizard-footer--done">
|
||||||
|
<button class="btn btn-primary" onclick="wizardFinish()">${t('wizard.done.finish')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attachStepListeners(step: WizardStep): void {
|
||||||
|
if (step === 'device') {
|
||||||
|
const form = document.getElementById('wizard-manual-form');
|
||||||
|
if (form) form.addEventListener('submit', (e) => void wizardAddManualDevice(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Re-scan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardRescan(): void {
|
||||||
|
if (!_state || _state.step !== 'device') return;
|
||||||
|
_startDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Finish
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardFinish(): void {
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
// Reload targets tab so the new target appears immediately
|
||||||
|
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Utility
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -44,7 +44,19 @@ const calibrationTutorialSteps: TutorialStep[] = [
|
|||||||
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const TOUR_KEY = 'tour_completed';
|
export const TOUR_KEY = 'tour_completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress the getting-started tour for this session AND permanently.
|
||||||
|
*
|
||||||
|
* Called by the setup wizard when it takes over the first-run experience so
|
||||||
|
* the tour never double-fires after the wizard completes. Setting the
|
||||||
|
* localStorage key mirrors what `onClose` would do when the tour finishes
|
||||||
|
* naturally.
|
||||||
|
*/
|
||||||
|
export function suppressGettingStartedTour(): void {
|
||||||
|
localStorage.setItem(TOUR_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
const gettingStartedSteps: TutorialStep[] = [
|
const gettingStartedSteps: TutorialStep[] = [
|
||||||
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
||||||
|
|||||||
+15
@@ -60,6 +60,21 @@ interface Window {
|
|||||||
selectDisplay: (...args: any[]) => any;
|
selectDisplay: (...args: any[]) => any;
|
||||||
formatDisplayLabel: (...args: any[]) => any;
|
formatDisplayLabel: (...args: any[]) => any;
|
||||||
|
|
||||||
|
// ─── Setup Wizard ───
|
||||||
|
openSetupWizard: () => void;
|
||||||
|
closeSetupWizard: () => void;
|
||||||
|
wizardNext: () => Promise<void>;
|
||||||
|
wizardBack: () => void;
|
||||||
|
wizardSkip: () => void;
|
||||||
|
wizardFinish: () => void;
|
||||||
|
wizardShowManual: () => void;
|
||||||
|
wizardHideManual: () => void;
|
||||||
|
wizardRescan: () => void;
|
||||||
|
wizardSelectDiscovered: (url: string, name: string, device_type: string) => Promise<void>;
|
||||||
|
wizardAddManualDevice: (event: Event) => Promise<void>;
|
||||||
|
wizardUseExistingDevice: (deviceId: string, deviceName: string) => void;
|
||||||
|
wizardSelectDisplay: (index: number, displayName: string) => void;
|
||||||
|
|
||||||
// ─── Tutorials ───
|
// ─── Tutorials ───
|
||||||
startCalibrationTutorial: (...args: any[]) => any;
|
startCalibrationTutorial: (...args: any[]) => any;
|
||||||
startDeviceTutorial: (...args: any[]) => any;
|
startDeviceTutorial: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -674,6 +674,7 @@
|
|||||||
"common.none_own_speed": "None (no sync)",
|
"common.none_own_speed": "None (no sync)",
|
||||||
"common.undo": "Undo",
|
"common.undo": "Undo",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
|
"common.back": "Back",
|
||||||
"common.apply": "Apply",
|
"common.apply": "Apply",
|
||||||
"common.start": "START",
|
"common.start": "START",
|
||||||
"common.stop": "STOP",
|
"common.stop": "STOP",
|
||||||
@@ -3243,5 +3244,65 @@
|
|||||||
"autocal.error.solve_failed": "Failed to solve calibration.",
|
"autocal.error.solve_failed": "Failed to solve calibration.",
|
||||||
"autocal.error.save_failed": "Failed to save calibration.",
|
"autocal.error.save_failed": "Failed to save calibration.",
|
||||||
"autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).",
|
"autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).",
|
||||||
"autocal.saved": "Calibration saved successfully."
|
"autocal.saved": "Calibration saved successfully.",
|
||||||
|
"wizard.modal.title": "Setup Wizard",
|
||||||
|
"wizard.rerun": "Rerun Setup Wizard",
|
||||||
|
"wizard.skip": "Skip",
|
||||||
|
"wizard.start": "Get Started",
|
||||||
|
"wizard.step.welcome": "Welcome",
|
||||||
|
"wizard.step.device": "Device",
|
||||||
|
"wizard.step.display": "Screen",
|
||||||
|
"wizard.step.scaffold": "Setup",
|
||||||
|
"wizard.step.calibrate": "Calibrate",
|
||||||
|
"wizard.step.start": "Start",
|
||||||
|
"wizard.step.done": "Done",
|
||||||
|
"wizard.welcome.title": "Welcome to LED Grab",
|
||||||
|
"wizard.welcome.desc": "Let's get your LED strip up and running in just a few steps.",
|
||||||
|
"wizard.welcome.item1": "Connect your LED controller",
|
||||||
|
"wizard.welcome.item2": "Choose your screen to capture",
|
||||||
|
"wizard.welcome.item3": "Calibrate your strip layout",
|
||||||
|
"wizard.welcome.item4": "Start the ambient light output",
|
||||||
|
"wizard.device.title": "Find Your Device",
|
||||||
|
"wizard.device.desc": "Scan the network for compatible LED controllers, or add one manually.",
|
||||||
|
"wizard.device.scanning": "Scanning network…",
|
||||||
|
"wizard.device.discovered": "Discovered on network",
|
||||||
|
"wizard.device.none_found": "No devices found. Try adding one manually.",
|
||||||
|
"wizard.device.rescan": "Rescan",
|
||||||
|
"wizard.device.existing": "Existing devices",
|
||||||
|
"wizard.device.manual.title": "Add Manually",
|
||||||
|
"wizard.device.manual.name": "Device Name",
|
||||||
|
"wizard.device.manual.name_placeholder": "My LED Strip",
|
||||||
|
"wizard.device.manual.url": "Device URL",
|
||||||
|
"wizard.device.manual.led_count": "LED Count",
|
||||||
|
"wizard.device.manual.add": "Add Device",
|
||||||
|
"wizard.display.title": "Choose Your Screen",
|
||||||
|
"wizard.display.desc": "Select the monitor or display you want to capture for ambient lighting.",
|
||||||
|
"wizard.display.loading": "Loading displays…",
|
||||||
|
"wizard.display.no_displays": "No displays detected. Enter the display index manually.",
|
||||||
|
"wizard.display.manual_index": "Display Index",
|
||||||
|
"wizard.display.primary": "Primary",
|
||||||
|
"wizard.display.index_prefix": "Display",
|
||||||
|
"wizard.display.confirm": "Use This Screen",
|
||||||
|
"wizard.scaffold.title": "Building Setup",
|
||||||
|
"wizard.scaffold.desc": "Creating the capture chain: screen source → color strip → LED output.",
|
||||||
|
"wizard.scaffold.building": "Creating entities…",
|
||||||
|
"wizard.scaffold.done": "Setup complete! Ready to calibrate.",
|
||||||
|
"wizard.calibrate.title": "Calibrate Strip Layout",
|
||||||
|
"wizard.calibrate.desc": "Tell LedGrab where your LED strip starts and how it runs around the screen.",
|
||||||
|
"wizard.calibrate.skip": "Skip Calibration",
|
||||||
|
"wizard.start.title": "Starting Output",
|
||||||
|
"wizard.start.starting": "Starting LED output…",
|
||||||
|
"wizard.start.done": "LED output is running!",
|
||||||
|
"wizard.start.failed": "Failed to start output. You can start it manually from the Targets tab.",
|
||||||
|
"wizard.done.title": "All Done!",
|
||||||
|
"wizard.done.desc": "Your ambient LED setup is active. Enjoy the light!",
|
||||||
|
"wizard.done.device": "Device",
|
||||||
|
"wizard.done.display": "Screen",
|
||||||
|
"wizard.done.finish": "Finish",
|
||||||
|
"wizard.error.no_device": "Please select or add a device first.",
|
||||||
|
"wizard.error.device_create_failed": "Failed to create device.",
|
||||||
|
"wizard.error.device_name_required": "Device name is required.",
|
||||||
|
"wizard.error.device_url_required": "Device URL is required.",
|
||||||
|
"wizard.error.scaffold_failed": "Setup failed. Please try again.",
|
||||||
|
"wizard.error.start_failed": "Failed to start LED output."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -731,6 +731,7 @@
|
|||||||
"common.none_own_speed": "Нет (своя скорость)",
|
"common.none_own_speed": "Нет (своя скорость)",
|
||||||
"common.undo": "Отменить",
|
"common.undo": "Отменить",
|
||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
|
"common.back": "Назад",
|
||||||
"common.apply": "Применить",
|
"common.apply": "Применить",
|
||||||
"common.start": "ПУСК",
|
"common.start": "ПУСК",
|
||||||
"common.stop": "СТОП",
|
"common.stop": "СТОП",
|
||||||
@@ -2925,5 +2926,65 @@
|
|||||||
"autocal.error.solve_failed": "Не удалось вычислить калибровку.",
|
"autocal.error.solve_failed": "Не удалось вычислить калибровку.",
|
||||||
"autocal.error.save_failed": "Не удалось сохранить калибровку.",
|
"autocal.error.save_failed": "Не удалось сохранить калибровку.",
|
||||||
"autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).",
|
"autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).",
|
||||||
"autocal.saved": "Калибровка успешно сохранена."
|
"autocal.saved": "Калибровка успешно сохранена.",
|
||||||
|
"wizard.modal.title": "Мастер настройки",
|
||||||
|
"wizard.rerun": "Запустить мастер настройки заново",
|
||||||
|
"wizard.skip": "Пропустить",
|
||||||
|
"wizard.start": "Начать",
|
||||||
|
"wizard.step.welcome": "Добро пожаловать",
|
||||||
|
"wizard.step.device": "Устройство",
|
||||||
|
"wizard.step.display": "Экран",
|
||||||
|
"wizard.step.scaffold": "Настройка",
|
||||||
|
"wizard.step.calibrate": "Калибровка",
|
||||||
|
"wizard.step.start": "Запуск",
|
||||||
|
"wizard.step.done": "Готово",
|
||||||
|
"wizard.welcome.title": "Добро пожаловать в LED Grab",
|
||||||
|
"wizard.welcome.desc": "Настроим вашу LED-ленту за несколько шагов.",
|
||||||
|
"wizard.welcome.item1": "Подключите контроллер LED",
|
||||||
|
"wizard.welcome.item2": "Выберите экран для захвата",
|
||||||
|
"wizard.welcome.item3": "Откалибруйте расположение ленты",
|
||||||
|
"wizard.welcome.item4": "Запустите подсветку",
|
||||||
|
"wizard.device.title": "Найдите устройство",
|
||||||
|
"wizard.device.desc": "Выполните сканирование сети или добавьте устройство вручную.",
|
||||||
|
"wizard.device.scanning": "Сканирование сети…",
|
||||||
|
"wizard.device.discovered": "Найдено в сети",
|
||||||
|
"wizard.device.none_found": "Устройства не найдены. Попробуйте добавить вручную.",
|
||||||
|
"wizard.device.rescan": "Повторить",
|
||||||
|
"wizard.device.existing": "Существующие устройства",
|
||||||
|
"wizard.device.manual.title": "Добавить вручную",
|
||||||
|
"wizard.device.manual.name": "Имя устройства",
|
||||||
|
"wizard.device.manual.name_placeholder": "Моя LED-лента",
|
||||||
|
"wizard.device.manual.url": "Адрес устройства",
|
||||||
|
"wizard.device.manual.led_count": "Количество светодиодов",
|
||||||
|
"wizard.device.manual.add": "Добавить устройство",
|
||||||
|
"wizard.display.title": "Выберите экран",
|
||||||
|
"wizard.display.desc": "Укажите монитор для захвата подсветки.",
|
||||||
|
"wizard.display.loading": "Загрузка дисплеев…",
|
||||||
|
"wizard.display.no_displays": "Дисплеи не найдены. Введите индекс вручную.",
|
||||||
|
"wizard.display.manual_index": "Индекс дисплея",
|
||||||
|
"wizard.display.primary": "Основной",
|
||||||
|
"wizard.display.index_prefix": "Дисплей",
|
||||||
|
"wizard.display.confirm": "Использовать этот экран",
|
||||||
|
"wizard.scaffold.title": "Создание конфигурации",
|
||||||
|
"wizard.scaffold.desc": "Создаём цепочку захвата: экран → цветовая лента → LED-выход.",
|
||||||
|
"wizard.scaffold.building": "Создание объектов…",
|
||||||
|
"wizard.scaffold.done": "Конфигурация создана! Готово к калибровке.",
|
||||||
|
"wizard.calibrate.title": "Калибровка ленты",
|
||||||
|
"wizard.calibrate.desc": "Укажите, где начинается лента и как она проходит вокруг экрана.",
|
||||||
|
"wizard.calibrate.skip": "Пропустить калибровку",
|
||||||
|
"wizard.start.title": "Запуск вывода",
|
||||||
|
"wizard.start.starting": "Запуск LED-вывода…",
|
||||||
|
"wizard.start.done": "LED-вывод работает!",
|
||||||
|
"wizard.start.failed": "Не удалось запустить. Запустите вручную на вкладке «Цели».",
|
||||||
|
"wizard.done.title": "Готово!",
|
||||||
|
"wizard.done.desc": "Ваша подсветка активна. Наслаждайтесь!",
|
||||||
|
"wizard.done.device": "Устройство",
|
||||||
|
"wizard.done.display": "Экран",
|
||||||
|
"wizard.done.finish": "Завершить",
|
||||||
|
"wizard.error.no_device": "Сначала выберите или добавьте устройство.",
|
||||||
|
"wizard.error.device_create_failed": "Не удалось создать устройство.",
|
||||||
|
"wizard.error.device_name_required": "Введите имя устройства.",
|
||||||
|
"wizard.error.device_url_required": "Введите адрес устройства.",
|
||||||
|
"wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.",
|
||||||
|
"wizard.error.start_failed": "Не удалось запустить LED-вывод."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -727,6 +727,7 @@
|
|||||||
"common.none_own_speed": "无(使用自身速度)",
|
"common.none_own_speed": "无(使用自身速度)",
|
||||||
"common.undo": "撤销",
|
"common.undo": "撤销",
|
||||||
"common.cancel": "取消",
|
"common.cancel": "取消",
|
||||||
|
"common.back": "返回",
|
||||||
"common.apply": "应用",
|
"common.apply": "应用",
|
||||||
"common.start": "启动",
|
"common.start": "启动",
|
||||||
"common.stop": "停止",
|
"common.stop": "停止",
|
||||||
@@ -2919,5 +2920,65 @@
|
|||||||
"autocal.error.solve_failed": "校准求解失败。",
|
"autocal.error.solve_failed": "校准求解失败。",
|
||||||
"autocal.error.save_failed": "保存校准数据失败。",
|
"autocal.error.save_failed": "保存校准数据失败。",
|
||||||
"autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。",
|
"autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。",
|
||||||
"autocal.saved": "校准已成功保存。"
|
"autocal.saved": "校准已成功保存。",
|
||||||
|
"wizard.modal.title": "设置向导",
|
||||||
|
"wizard.rerun": "重新运行设置向导",
|
||||||
|
"wizard.skip": "跳过",
|
||||||
|
"wizard.start": "开始设置",
|
||||||
|
"wizard.step.welcome": "欢迎",
|
||||||
|
"wizard.step.device": "设备",
|
||||||
|
"wizard.step.display": "屏幕",
|
||||||
|
"wizard.step.scaffold": "配置",
|
||||||
|
"wizard.step.calibrate": "校准",
|
||||||
|
"wizard.step.start": "启动",
|
||||||
|
"wizard.step.done": "完成",
|
||||||
|
"wizard.welcome.title": "欢迎使用 LED Grab",
|
||||||
|
"wizard.welcome.desc": "只需几步,即可启动并运行您的 LED 灯带。",
|
||||||
|
"wizard.welcome.item1": "连接您的 LED 控制器",
|
||||||
|
"wizard.welcome.item2": "选择要采集的屏幕",
|
||||||
|
"wizard.welcome.item3": "校准灯带布局",
|
||||||
|
"wizard.welcome.item4": "启动氛围灯输出",
|
||||||
|
"wizard.device.title": "查找您的设备",
|
||||||
|
"wizard.device.desc": "扫描网络查找兼容的 LED 控制器,或手动添加。",
|
||||||
|
"wizard.device.scanning": "正在扫描网络…",
|
||||||
|
"wizard.device.discovered": "在网络中发现",
|
||||||
|
"wizard.device.none_found": "未找到设备。请尝试手动添加。",
|
||||||
|
"wizard.device.rescan": "重新扫描",
|
||||||
|
"wizard.device.existing": "已有设备",
|
||||||
|
"wizard.device.manual.title": "手动添加",
|
||||||
|
"wizard.device.manual.name": "设备名称",
|
||||||
|
"wizard.device.manual.name_placeholder": "我的 LED 灯带",
|
||||||
|
"wizard.device.manual.url": "设备地址",
|
||||||
|
"wizard.device.manual.led_count": "LED 数量",
|
||||||
|
"wizard.device.manual.add": "添加设备",
|
||||||
|
"wizard.display.title": "选择您的屏幕",
|
||||||
|
"wizard.display.desc": "选择用于采集氛围灯的显示器。",
|
||||||
|
"wizard.display.loading": "正在加载显示器…",
|
||||||
|
"wizard.display.no_displays": "未检测到显示器。请手动输入显示器序号。",
|
||||||
|
"wizard.display.manual_index": "显示器序号",
|
||||||
|
"wizard.display.primary": "主显示器",
|
||||||
|
"wizard.display.index_prefix": "显示器",
|
||||||
|
"wizard.display.confirm": "使用此屏幕",
|
||||||
|
"wizard.scaffold.title": "正在创建配置",
|
||||||
|
"wizard.scaffold.desc": "正在创建采集链:屏幕源 → 色带 → LED 输出。",
|
||||||
|
"wizard.scaffold.building": "正在创建实体…",
|
||||||
|
"wizard.scaffold.done": "配置完成!准备好进行校准。",
|
||||||
|
"wizard.calibrate.title": "校准灯带布局",
|
||||||
|
"wizard.calibrate.desc": "告诉 LedGrab 您的 LED 灯带从哪里开始,以及它如何绕屏幕布置。",
|
||||||
|
"wizard.calibrate.skip": "跳过校准",
|
||||||
|
"wizard.start.title": "正在启动输出",
|
||||||
|
"wizard.start.starting": "正在启动 LED 输出…",
|
||||||
|
"wizard.start.done": "LED 输出正在运行!",
|
||||||
|
"wizard.start.failed": "启动输出失败。您可以在「目标」选项卡中手动启动。",
|
||||||
|
"wizard.done.title": "全部完成!",
|
||||||
|
"wizard.done.desc": "您的氛围 LED 设置已激活。尽情享受灯光吧!",
|
||||||
|
"wizard.done.device": "设备",
|
||||||
|
"wizard.done.display": "屏幕",
|
||||||
|
"wizard.done.finish": "完成",
|
||||||
|
"wizard.error.no_device": "请先选择或添加一个设备。",
|
||||||
|
"wizard.error.device_create_failed": "创建设备失败。",
|
||||||
|
"wizard.error.device_name_required": "设备名称不能为空。",
|
||||||
|
"wizard.error.device_url_required": "设备地址不能为空。",
|
||||||
|
"wizard.error.scaffold_failed": "配置失败,请重试。",
|
||||||
|
"wizard.error.start_failed": "启动 LED 输出失败。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@
|
|||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
||||||
<span class="header-toolbar-sep"></span>
|
<span class="header-toolbar-sep"></span>
|
||||||
|
<button class="header-btn" id="wizard-rerun-btn" onclick="openSetupWizard()" data-i18n-title="wizard.rerun" title="Setup Wizard">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
</button>
|
||||||
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
|
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
|
||||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -222,6 +225,7 @@
|
|||||||
|
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
{% include 'modals/setup-wizard.html' %}
|
||||||
{% include 'modals/calibration.html' %}
|
{% include 'modals/calibration.html' %}
|
||||||
{% include 'modals/advanced-calibration.html' %}
|
{% include 'modals/advanced-calibration.html' %}
|
||||||
{% include 'modals/auto-calibration.html' %}
|
{% include 'modals/auto-calibration.html' %}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Setup Wizard Modal — first-run guided setup.
|
||||||
|
Opened automatically on first visit (app.ts checks onboarding flag)
|
||||||
|
and can be reopened via the toolbar wizard button.
|
||||||
|
|
||||||
|
Channel: accent (green) — same as the main calibration modal.
|
||||||
|
All step rendering is handled by setup-wizard.ts. -->
|
||||||
|
<div id="setup-wizard-modal" class="modal" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="wizard-modal-title">
|
||||||
|
<div class="modal-content" style="max-width:600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="wizard-modal-title">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||||
|
<path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="wizard.modal.title">Setup Wizard</span>
|
||||||
|
</h2>
|
||||||
|
<button class="modal-close-btn" onclick="wizardSkip()"
|
||||||
|
title="Skip" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 20px 24px;">
|
||||||
|
<!-- Progress bar and pip indicators -->
|
||||||
|
<div id="wizard-progress-bar" class="wizard-progress-bar"></div>
|
||||||
|
<div id="wizard-progress-labels" class="wizard-progress-labels"></div>
|
||||||
|
|
||||||
|
<!-- Step container: setup-wizard.ts mounts here -->
|
||||||
|
<div id="wizard-step-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user