diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 2a00cc7..a8b1466 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -2651,3 +2651,371 @@ textarea:focus-visible { .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; } +} + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 8eb13fe..9c018d5 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -36,7 +36,16 @@ import { startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial, startIntegrationsTutorial, closeTutorial, tutorialNext, tutorialPrev, + TOUR_KEY, } 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 import { @@ -329,6 +338,21 @@ Object.assign(window, { selectDisplay, formatDisplayLabel, + // setup wizard + openSetupWizard, + closeSetupWizard, + wizardNext, + wizardBack, + wizardSkip, + wizardFinish, + wizardShowManual, + wizardHideManual, + wizardRescan, + wizardSelectDiscovered, + wizardAddManualDevice, + wizardUseExistingDevice, + wizardSelectDisplay, + // tutorials startCalibrationTutorial, startDeviceTutorial, @@ -951,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => { setProjectUrls(serverRepoUrl, serverDonateUrl); initDonationBanner(); - // Show getting-started tutorial on first visit - if (!localStorage.getItem('tour_completed')) { + // First-run: wizard wins over the tooltip tour. + // + // 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); } } catch (err) { diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts new file mode 100644 index 0000000..bb002e2 --- /dev/null +++ b/server/src/ledgrab/static/js/features/setup-wizard.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + if (!_state || _state.busy) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + const body: Record = { + name, + device_type, + url, + led_count: 60, + }; + const device = await apiPost('/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 { + 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('/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 { + 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 { + if (!_state) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + const result = await apiPost('/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 { + if (!_state?.scaffoldResult) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + await apiPost(`/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 = ` +
+
+
+ `; + + 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 `${done ? ICON_CHECK : String(i + 1)}`; + }).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 `
+ + ${msg} +
`; +} + +function _buildWelcomeStep(): string { + return `
+
${ICON_SPARKLES}
+

${t('wizard.welcome.title')}

+

${t('wizard.welcome.desc')}

+
    +
  • ${ICON_DEVICE}${t('wizard.welcome.item1')}
  • +
  • ${ICON_MONITOR}${t('wizard.welcome.item2')}
  • +
  • ${ICON_CALIBRATION}${t('wizard.welcome.item3')}
  • +
  • ${ICON_START}${t('wizard.welcome.item4')}
  • +
+ +
`; +} + +function _buildDeviceStep(state: WizardState): string { + const existingDevices: Device[] = devicesCache.data || []; + + let discoveryHtml = ''; + if (state.busy && state.discoveredDevices.length === 0) { + discoveryHtml = `
+
+ ${t('wizard.device.scanning')} +
`; + } else if (state.discoveredDevices.length > 0) { + discoveryHtml = `
` + + state.discoveredDevices.map(d => ` + `).join('') + + `
`; + } else { + discoveryHtml = `
+ ${t('wizard.device.none_found')} +
`; + } + + let existingHtml = ''; + if (existingDevices.length > 0) { + existingHtml = ` +
` + + existingDevices.map(d => ` + `).join('') + + `
`; + } + + let manualHtml = ''; + if (state.manualMode) { + manualHtml = `
+
+ + +
+
+ + +
+
+ + +
+ ${_errorBanner(state.errorMsg)} + +
`; + } else { + manualHtml = ''; + } + + return `
+
+
${ICON_DEVICE}
+
+

${t('wizard.device.title')}

+

${t('wizard.device.desc')}

+
+
+ ${!state.manualMode ? ` +
+ + ${discoveryHtml} +
+ ${existingHtml} + ${_errorBanner(state.errorMsg)} + + ` : manualHtml} +
`; +} + +function _buildDisplayStep(state: WizardState): string { + const displays: Display[] = displaysCache.data ?? []; + + let listHtml = ''; + if (state.busy && displays.length === 0) { + listHtml = `
+
+ ${t('wizard.display.loading')} +
`; + } else if (displays.length === 0) { + // Fallback: offer a manual index input + listHtml = `
+

${t('wizard.display.no_displays')}

+
+ + +
+
`; + } else { + listHtml = `
` + + displays.map(d => { + const active = d.index === state.displayIndex; + return ``; + }).join('') + + `
`; + } + + return `
+
+
${ICON_MONITOR}
+
+

${t('wizard.display.title')}

+

${t('wizard.display.desc')}

+
+
+ ${listHtml} + ${_errorBanner(state.errorMsg)} + +
`; +} + +function _buildScaffoldStep(state: WizardState): string { + return `
+
+
${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}
+
+

${t('wizard.scaffold.title')}

+

${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}

+
+
+ ${state.busy ? `
+
+ ${t('wizard.scaffold.building')} +
` : ''} + ${_errorBanner(state.errorMsg)} +
`; +} + +function _buildCalibrateStep(state: WizardState): string { + return `
+
+
${ICON_CALIBRATION}
+
+

${t('wizard.calibrate.title')}

+

${t('wizard.calibrate.desc')}

+
+
+ +
+ +
`; +} + +function _buildStartStep(state: WizardState): string { + return `
+
+
${START_STEP_ICON(state)}
+
+

${t('wizard.start.title')}

+

${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}

+
+
+ ${state.busy ? `
+
+ ${t('wizard.start.starting')} +
` : ''} + ${_errorBanner(state.errorMsg)} +
`; +} + +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 `
+
${ICON_ROCKET_ICON}
+

${t('wizard.done.title')}

+

${t('wizard.done.desc')}

+ ${state.scaffoldResult ? `
+
+ ${t('wizard.done.device')} + ${_esc(state.deviceName)} +
+
+ ${t('wizard.done.display')} + ${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))} +
+
` : ''} + +
`; +} + +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, '''); +} diff --git a/server/src/ledgrab/static/js/features/tutorials.ts b/server/src/ledgrab/static/js/features/tutorials.ts index 99b27f7..117ad71 100644 --- a/server/src/ledgrab/static/js/features/tutorials.ts +++ b/server/src/ledgrab/static/js/features/tutorials.ts @@ -44,7 +44,19 @@ const calibrationTutorialSteps: TutorialStep[] = [ { 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[] = [ { selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' }, diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 3e0ba42..4ff0677 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -60,6 +60,21 @@ interface Window { selectDisplay: (...args: any[]) => any; formatDisplayLabel: (...args: any[]) => any; + // ─── Setup Wizard ─── + openSetupWizard: () => void; + closeSetupWizard: () => void; + wizardNext: () => Promise; + wizardBack: () => void; + wizardSkip: () => void; + wizardFinish: () => void; + wizardShowManual: () => void; + wizardHideManual: () => void; + wizardRescan: () => void; + wizardSelectDiscovered: (url: string, name: string, device_type: string) => Promise; + wizardAddManualDevice: (event: Event) => Promise; + wizardUseExistingDevice: (deviceId: string, deviceName: string) => void; + wizardSelectDisplay: (index: number, displayName: string) => void; + // ─── Tutorials ─── startCalibrationTutorial: (...args: any[]) => any; startDeviceTutorial: (...args: any[]) => any; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 6c5aba1..33d6e27 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -674,6 +674,7 @@ "common.none_own_speed": "None (no sync)", "common.undo": "Undo", "common.cancel": "Cancel", + "common.back": "Back", "common.apply": "Apply", "common.start": "START", "common.stop": "STOP", @@ -3243,5 +3244,65 @@ "autocal.error.solve_failed": "Failed to solve 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.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." } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index b9d13a1..4f5047c 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -731,6 +731,7 @@ "common.none_own_speed": "Нет (своя скорость)", "common.undo": "Отменить", "common.cancel": "Отмена", + "common.back": "Назад", "common.apply": "Применить", "common.start": "ПУСК", "common.stop": "СТОП", @@ -2925,5 +2926,65 @@ "autocal.error.solve_failed": "Не удалось вычислить калибровку.", "autocal.error.save_failed": "Не удалось сохранить калибровку.", "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-вывод." } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 9bd60bd..66097cb 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -727,6 +727,7 @@ "common.none_own_speed": "无(使用自身速度)", "common.undo": "撤销", "common.cancel": "取消", + "common.back": "返回", "common.apply": "应用", "common.start": "启动", "common.stop": "停止", @@ -2919,5 +2920,65 @@ "autocal.error.solve_failed": "校准求解失败。", "autocal.error.save_failed": "保存校准数据失败。", "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 输出失败。" } diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 706c385..e269142 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -75,6 +75,9 @@
API + @@ -222,6 +225,7 @@
+ {% include 'modals/setup-wizard.html' %} {% include 'modals/calibration.html' %} {% include 'modals/advanced-calibration.html' %} {% include 'modals/auto-calibration.html' %} diff --git a/server/src/ledgrab/templates/modals/setup-wizard.html b/server/src/ledgrab/templates/modals/setup-wizard.html new file mode 100644 index 0000000..965e428 --- /dev/null +++ b/server/src/ledgrab/templates/modals/setup-wizard.html @@ -0,0 +1,30 @@ + +