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,238 @@
/**
* Display picker lightbox — display selection for streams and tests.
* Supports engine-specific displays (e.g. scrcpy → Android devices).
*/
import {
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
availableEngines,
} from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth } from '../core/api.ts';
import { showToast } from '../core/ui.ts';
import type { Display } from '../types.ts';
/** Currently active engine type for the picker (null = desktop monitors). */
let _pickerEngineType: string | null = null;
/** Check if an engine type has its own device list (for inline onclick use). */
window._engineHasOwnDisplays = (engineType: string) =>
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
export function openDisplayPicker(callback: (index: number, display?: Display | null) => void, selectedIndex: number | string | null | undefined, engineType: string | null = null): void {
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox');
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
if (titleEl) {
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
}
lightbox.classList.add('active');
requestAnimationFrame(() => {
// Always fetch fresh when engine type is specified (different list each time)
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
const displays = data.displays || [];
// Store in cache so selectDisplay() can look them up
displaysCache.update(displays);
if (displays.length > 0) {
renderDisplayPickerLayout(displays, engineType);
} else if (engineType === 'scrcpy') {
_renderEmptyAndroidPicker(canvas);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
} catch (error) {
console.error('Error fetching engine displays:', error);
canvas.innerHTML = `<div class="loading">${t('displays.failed')}</div>`;
}
}
function _renderEmptyAndroidPicker(canvas: HTMLElement): void {
canvas.innerHTML = `
<div class="loading">${t('displays.picker.no_android')}</div>
${_buildAdbConnectHtml()}
`;
}
function _buildAdbConnectHtml(): string {
return `
<div class="adb-connect-form" style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; justify-content: center;">
<input type="text" id="adb-connect-ip"
placeholder="${t('displays.picker.adb_connect.placeholder')}"
style="width: 200px; padding: 0.35rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color);">
<button class="btn btn-primary btn-sm" onclick="window._adbConnectFromPicker()">
${t('displays.picker.adb_connect.button')}
</button>
</div>
`;
}
/** Called from the inline Connect button inside the display picker. */
window._adbConnectFromPicker = async function () {
const input = document.getElementById('adb-connect-ip') as HTMLInputElement | null;
if (!input) return;
const address = input.value.trim();
if (!address) return;
input.disabled = true;
try {
const resp = await fetchWithAuth('/adb/connect', {
method: 'POST',
body: JSON.stringify({ address }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Connection failed');
}
showToast(t('displays.picker.adb_connect.success'), 'success');
// Refresh the picker with updated device list
if (_pickerEngineType) {
await _fetchAndRenderEngineDisplays(_pickerEngineType);
}
} catch (error) {
showToast(`${t('displays.picker.adb_connect.error')}: ${error.message}`, 'error');
} finally {
if (input) input.disabled = false;
}
};
export function closeDisplayPicker(event?: Event): void {
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
set_displayPickerCallback(null);
_pickerEngineType = null;
}
export function selectDisplay(displayIndex: number): void {
// Re-read live bindings
import('../core/state.js').then(({ _displayPickerCallback: cb, _cachedDisplays: displays }) => {
if (cb) {
const display = displays ? displays.find(d => d.index === displayIndex) : null;
cb(displayIndex, display);
}
closeDisplayPicker();
});
}
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): void {
const canvas = document.getElementById('display-picker-canvas');
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
return;
}
// Engines with own displays (camera, scrcpy) → device list layout
if (engineType) {
const items = displays.map(display => {
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="device-picker-item layout-display-pickable${isSelected ? ' selected' : ''}"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height} · ${display.refresh_rate} FPS</small>
</div>
</div>
`;
}).join('');
let html = `<div class="device-picker-list">${items}</div>`;
if (engineType === 'scrcpy') {
html += _buildAdbConnectHtml();
}
canvas.innerHTML = html;
return;
}
// Desktop monitors → positioned rectangle layout
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
displays.forEach(display => {
minX = Math.min(minX, display.x);
minY = Math.min(minY, display.y);
maxX = Math.max(maxX, display.x + display.width);
maxY = Math.max(maxY, display.y + display.height);
});
const totalWidth = maxX - minX;
const totalHeight = maxY - minY;
const aspect = totalHeight / totalWidth;
const displayElements = displays.map(display => {
const leftPct = ((display.x - minX) / totalWidth) * 100;
const topPct = ((display.y - minY) / totalHeight) * 100;
const widthPct = (display.width / totalWidth) * 100;
const heightPct = (display.height / totalHeight) * 100;
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="layout-display layout-display-pickable${isSelected ? ' selected' : ''}"
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
<div class="layout-position-label">(${display.x}, ${display.y})</div>
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div>
</div>
`;
}).join('');
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</div>
`;
}
export function formatDisplayLabel(displayIndex: number, display: any | null, engineType: string | null = null): string {
if (display) {
if (engineType && window._engineHasOwnDisplays(engineType)) {
return `${display.name} (${display.width}×${display.height})`;
}
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;
}