Migrate frontend from JavaScript to TypeScript

- Rename all 54 .js files to .ts, update esbuild entry point
- Add tsconfig.json, TypeScript devDependency, typecheck script
- Create types.ts with 25+ interfaces matching backend Pydantic schemas
  (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource,
  AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.)
- Make DataCache generic (DataCache<T>) with typed state instances
- Type all state variables in state.ts with proper entity types
- Type all create*Card functions with proper entity interfaces
- Type all function parameters and return types across all 54 files
- Type core component constructors (CardSection, IconSelect, EntitySelect,
  FilterList, TagInput, TreeNav, Modal) with exported option interfaces
- Add comprehensive global.d.ts for window function declarations
- Type fetchWithAuth with FetchAuthOpts interface
- Remove all (window as any) casts in favor of global.d.ts declarations
- Zero tsc errors, esbuild bundle unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:08:23 +03:00
parent 55772b58dd
commit 997ff2fd70
61 changed files with 5382 additions and 3833 deletions

View File

@@ -0,0 +1,406 @@
/**
* Tutorial system — generic engine, steps, tooltip positioning.
*/
import { activeTutorial, setActiveTutorial } from '../core/state.ts';
import { t } from '../core/i18n.ts';
interface TutorialStep {
selector: string;
textKey: string;
position: string;
global?: boolean;
}
interface TutorialConfig {
steps: TutorialStep[];
overlayId: string;
mode: string;
container: Element | null;
resolveTarget: (step: TutorialStep) => Element | null;
onClose?: (() => void) | null;
}
const calibrationTutorialSteps: TutorialStep[] = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '#calibration-overlay-btn', textKey: 'calibration.tip.overlay', position: 'bottom' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
];
const TOUR_KEY = 'tour_completed';
const gettingStartedSteps: TutorialStep[] = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' },
{ selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },
{ selector: '#cp-wrap-accent', textKey: 'tour.accent', position: 'bottom' },
{ selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' },
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
];
const dashboardTutorialSteps: TutorialStep[] = [
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
{ selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' }
];
const targetsTutorialSteps: TutorialStep[] = [
{ selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' },
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
{ selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' }
];
const sourcesTourSteps: TutorialStep[] = [
{ selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
{ selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="color_strip"]', textKey: 'tour.src.color_strip', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' }
];
const automationsTutorialSteps: TutorialStep[] = [
{ selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' },
{ selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' },
{ selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' },
{ selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom' },
{ selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom' },
{ selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom' },
];
const _fixedResolve = (step: TutorialStep): Element | null => {
const el = document.querySelector(step.selector);
if (!el) return null;
// offsetParent is null when element or any ancestor has display:none
if (!(el as HTMLElement).offsetParent) return null;
return el;
};
const deviceTutorialSteps: TutorialStep[] = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
export function startTutorial(config: TutorialConfig): void {
closeTutorial();
// Remove focus from the trigger button so its outline doesn't persist
if (document.activeElement) (document.activeElement as HTMLElement).blur();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;
setActiveTutorial({
steps: config.steps,
overlay: overlay,
mode: config.mode,
step: 0,
resolveTarget: config.resolveTarget,
container: config.container,
onClose: config.onClose || null
});
// Hide tooltip and ring until first step positions them (prevents flash at 0,0)
const tooltip = overlay.querySelector('.tutorial-tooltip');
const ring = overlay.querySelector('.tutorial-ring');
const backdrop = overlay.querySelector('.tutorial-backdrop');
if (tooltip) (tooltip as HTMLElement).style.visibility = 'hidden';
if (ring) (ring as HTMLElement).style.visibility = 'hidden';
if (backdrop) (backdrop as HTMLElement).style.clipPath = 'none';
overlay.classList.add('active');
document.addEventListener('keydown', handleTutorialKey);
showTutorialStep(0);
}
export function startCalibrationTutorial(): void {
const container = document.querySelector('#calibration-modal .modal-body');
if (!container) return;
startTutorial({
steps: calibrationTutorialSteps,
overlayId: 'calibration-tutorial-overlay',
mode: 'absolute',
container: container,
resolveTarget: (step: TutorialStep): Element | null => {
const el = document.querySelector(step.selector) as HTMLElement | null;
if (!el) return null;
// Skip elements hidden via display:none (e.g. overlay btn in device mode)
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') return null;
return el;
}
});
}
export function startDeviceTutorial(deviceId?: string): void {
const selector = deviceId
? `.card[data-device-id="${deviceId}"]`
: '.card[data-device-id]';
if (!document.querySelector(selector)) return;
startTutorial({
steps: deviceTutorialSteps,
overlayId: 'device-tutorial-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step: TutorialStep): Element | null => {
const card = document.querySelector(selector);
if (!card) return null;
return step.global
? document.querySelector(step.selector)
: card.querySelector(step.selector);
}
});
}
export function startGettingStartedTutorial(): void {
startTutorial({
steps: gettingStartedSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step: TutorialStep): Element | null => document.querySelector(step.selector) || null,
onClose: () => localStorage.setItem(TOUR_KEY, '1')
});
}
export function startDashboardTutorial(): void {
startTutorial({
steps: dashboardTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startTargetsTutorial(): void {
startTutorial({
steps: targetsTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startSourcesTutorial(): void {
startTutorial({
steps: sourcesTourSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startAutomationsTutorial(): void {
startTutorial({
steps: automationsTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function closeTutorial(): void {
if (!activeTutorial) return;
const onClose = activeTutorial.onClose;
activeTutorial.overlay.classList.remove('active');
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
(el as HTMLElement).style.zIndex = '';
});
document.removeEventListener('keydown', handleTutorialKey);
setActiveTutorial(null);
if (onClose) onClose();
}
export function tutorialNext(): void {
if (!activeTutorial) return;
if (activeTutorial.step < activeTutorial.steps.length - 1) {
showTutorialStep(activeTutorial.step + 1);
} else {
closeTutorial();
}
}
export function tutorialPrev(): void {
if (!activeTutorial) return;
if (activeTutorial.step > 0) {
showTutorialStep(activeTutorial.step - 1, -1);
}
}
function _positionSpotlight(target: Element, overlay: HTMLElement, step: TutorialStep, index: number, isFixed: boolean): void {
const targetRect = target.getBoundingClientRect();
const pad = 6;
let x, y, w, h;
if (isFixed) {
x = targetRect.left - pad;
y = targetRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
} else {
const containerRect = activeTutorial.container.getBoundingClientRect();
x = targetRect.left - containerRect.left - pad;
y = targetRect.top - containerRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
}
const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement;
if (backdrop) {
backdrop.style.clipPath = `polygon(
0% 0%, 0% 100%,
${x}px 100%, ${x}px ${y}px,
${x + w}px ${y}px, ${x + w}px ${y + h}px,
${x}px ${y + h}px, ${x}px 100%,
100% 100%, 100% 0%)`;
}
const ring = overlay.querySelector('.tutorial-ring') as HTMLElement;
if (ring) {
ring.style.left = x + 'px';
ring.style.top = y + 'px';
ring.style.width = w + 'px';
ring.style.height = h + 'px';
}
const tooltip = overlay.querySelector('.tutorial-tooltip') as HTMLElement;
const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey);
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
const prevBtn = overlay.querySelector('.tutorial-prev-btn') as HTMLButtonElement;
const nextBtn = overlay.querySelector('.tutorial-next-btn');
if (prevBtn) prevBtn.disabled = (index === 0);
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
tooltip.style.visibility = '';
}
if (ring) ring.style.visibility = '';
}
/** Wait for scroll to settle (position stops changing). */
function _waitForScrollEnd(): Promise<void> {
return new Promise<void>(resolve => {
let lastY = window.scrollY;
let stableFrames = 0;
const fallback = setTimeout(resolve, 300);
function check() {
const y = window.scrollY;
if (y === lastY) stableFrames++;
else { stableFrames = 0; lastY = y; }
if (stableFrames >= 3) { clearTimeout(fallback); resolve(); }
else requestAnimationFrame(check);
}
requestAnimationFrame(check);
});
}
function showTutorialStep(index: number, direction: number = 1): void {
if (!activeTutorial) return;
activeTutorial.step = index;
const step = activeTutorial.steps[index];
const overlay = activeTutorial.overlay;
const isFixed = activeTutorial.mode === 'fixed';
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
(el as HTMLElement).style.zIndex = '';
});
const target = activeTutorial.resolveTarget(step);
if (!target) {
// Auto-skip hidden/missing targets in the current direction
const next = index + direction;
if (next >= 0 && next < activeTutorial.steps.length) showTutorialStep(next, direction);
else closeTutorial();
return;
}
target.classList.add('tutorial-target');
if (isFixed) (target as HTMLElement).style.zIndex = '10001';
// Scroll target into view if off-screen or behind sticky header
const preRect = target.getBoundingClientRect();
const headerH = isFixed ? (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0) : 0;
const needsScroll = preRect.bottom > window.innerHeight || preRect.top < headerH;
if (needsScroll) {
// Hide tooltip while scrolling to prevent stale position flash
const tt = overlay.querySelector('.tutorial-tooltip');
if (tt) tt.style.visibility = 'hidden';
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
_waitForScrollEnd().then(() => {
if (activeTutorial && activeTutorial.step === index) {
_positionSpotlight(target, overlay, step, index, isFixed);
}
});
} else {
_positionSpotlight(target, overlay, step, index, isFixed);
}
}
function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): void {
const gap = 12;
const tooltipW = 260;
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150;
const positions = {
top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
};
let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const alt = positions[opposite[preferred]];
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt;
}
}
pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
}
function handleTutorialKey(e: KeyboardEvent): void {
if (!activeTutorial) return;
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}