Compare commits

...

11 Commits

Author SHA1 Message Date
47c696bae3 Frontend improvements: CSS foundations, accessibility, UX enhancements
CSS: Add design token variables (spacing, timing, weights, z-index layers),
migrate all hardcoded z-index to named vars, fix light theme contrast for
WCAG AA, add skeleton loading cards, mask-composite fallback, card padding.

Accessibility: aria-live on toast, aria-label on health dots, sr-only class,
graph container keyboard focusable, MQTT password wrapped in form element.

UX: Modal auto-focus on open, inline field validation with blur, undo toast
with countdown, bulk action progress indicator, API error toast on failure.

i18n: Add common.undo, validation.required, bulk.processing, api.error.*
keys in EN/RU/ZH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:51:22 +03:00
43fbc1eff5 Fix modal-open layout shift caused by position:fixed scroll lock
Replace the body position:fixed hack with overflow:hidden on html element,
which works cleanly with scrollbar-gutter:stable to prevent layout shift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:37:10 +03:00
997ff2fd70 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>
2026-03-19 13:08:23 +03:00
55772b58dd Replace deploy workflow with portable Windows release build
- Remove old Docker-based deploy.yml
- Add release.yml: builds portable ZIP on tag push, uploads to Gitea
- Add build-dist.ps1: downloads embedded Python, installs deps, bundles app
- Add scrollbar-gutter: stable to prevent layout shift

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:55 +03:00
968046d96b HA integration: fix reload loop, parallel device fetch, WS guards, translations
- Fix indentation bug causing scenes device to not register
- Use nonlocal tracking to prevent infinite reload loops on target/scene changes
- Guard WS start/stop to avoid redundant connections
- Parallel device brightness fetching via asyncio.gather
- Route set_leds service to correct coordinator by source ID
- Remove stale pattern cache, reuse single timeout object
- Fix translations structure for light/select entities
- Unregister service when last config entry unloaded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:46 +03:00
122e95545c Card bulk operations, remove expand/collapse, graph color picker fix
- Bulk selection mode: Ctrl+Click or toggle button to enter, Escape to exit
- Shift+Click for range select, bottom toolbar with SVG icon action buttons
- All CardSections wired with bulk actions: Delete everywhere,
  Start/Stop for targets, Enable/Disable for automations
- Remove expand/collapse all buttons (no collapsible sections remain)
- Fix graph node color picker overlay persisting after outside click
- Add Icons section to frontend.md conventions
- Add trash2, listChecks, circleOff icons to icon system
- Backend: processing loop performance improvements (monotonic timestamps,
  deque-based FPS tracking)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:21:27 +03:00
f4647027d2 Show actual API error details in modal save/create failures
Previously modals showed generic "Failed to add/save" messages. Now they
extract and display the actual error detail from the API response (e.g.,
"URL is required", "Name already exists"). Handles Pydantic validation
arrays by joining msg fields.

Fixed in 8 files: device-discovery, devices, calibration,
advanced-calibration, scene-presets, automations, command-palette.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:19:08 +03:00
cdba98813b Backend performance and code quality improvements
Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:06:29 +03:00
1f047d6561 KC test uses shared LiveStreamManager, tree-nav dropdown, KC card badge fix
- KC test WS now acquires from LiveStreamManager instead of creating its
  own DXGI duplicator, eliminating capture contention with running LED targets
- Tree-nav refactored to compact dropdown on click with outside-click dismiss
  (closes on click outside the trigger+panel, not just outside the container)
- KC target card badge (e.g. "Daylight Cycle") no longer wastes empty space

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:50:33 +03:00
6a31814900 Fix scroll position reset when closing modals
- Use { behavior: 'instant' } in unlockBody scrollTo to override
  CSS scroll-behavior: smooth on html element
- Use { preventScroll: true } on focus() restore in Modal.forceClose
- Add overflow-y: scroll to body.modal-open to prevent scrollbar shift

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:39:53 +03:00
ea9b05733b Dim non-related edges and flow dots when a graph node is selected
- Fix CSS specificity: .dimmed now overrides .graph-edge-active opacity/filter
- Add data-from/data-to to flow dot groups so they can be dimmed per-edge
- Dim flow dots on non-chain edges in highlightChain(), restore on clear

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:14:32 +03:00
125 changed files with 7794 additions and 4759 deletions

View File

@@ -1,31 +0,0 @@
name: Build and Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to /opt/wled-controller
run: |
DEPLOY_DIR=/opt/wled-controller
# Ensure deploy directory exists
mkdir -p "$DEPLOY_DIR/data" "$DEPLOY_DIR/logs" "$DEPLOY_DIR/config"
# Copy server files to deploy directory
rsync -a --delete \
--exclude 'data/' \
--exclude 'logs/' \
server/ "$DEPLOY_DIR/"
# Build and restart
cd "$DEPLOY_DIR"
docker compose down
docker compose up -d --build

View File

@@ -0,0 +1,74 @@
name: Build Release
on:
push:
tags:
- 'v*'
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build portable distribution
shell: pwsh
run: |
.\build-dist.ps1 -Version "${{ gitea.ref_name }}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ gitea.ref_name }}-win-x64
path: build/LedGrab-*.zip
retention-days: 90
- name: Create Gitea release
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$tag = "${{ gitea.ref_name }}"
$zipFile = Get-ChildItem "build\LedGrab-*.zip" | Select-Object -First 1
if (-not $zipFile) { throw "ZIP not found" }
$baseUrl = "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
$headers = @{
"Authorization" = "token $env:GITEA_TOKEN"
"Content-Type" = "application/json"
}
# Create release
$body = @{
tag_name = $tag
name = "LedGrab $tag"
body = "Portable Windows build — unzip, run ``LedGrab.bat``, open http://localhost:8080"
draft = $false
prerelease = ($tag -match '(alpha|beta|rc)')
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post `
-Uri "$baseUrl/releases" `
-Headers $headers -Body $body
Write-Host "Created release: $($release.html_url)"
# Upload ZIP asset
$uploadHeaders = @{
"Authorization" = "token $env:GITEA_TOKEN"
}
$uploadUrl = "$baseUrl/releases/$($release.id)/assets?name=$($zipFile.Name)"
Invoke-RestMethod -Method Post -Uri $uploadUrl `
-Headers $uploadHeaders `
-ContentType "application/octet-stream" `
-InFile $zipFile.FullName
Write-Host "Uploaded: $($zipFile.Name)"

250
build-dist.ps1 Normal file
View File

@@ -0,0 +1,250 @@
<#
.SYNOPSIS
Build a portable Windows distribution of LedGrab.
.DESCRIPTION
Downloads embedded Python, installs all dependencies, copies app code,
builds the frontend bundle, and produces a self-contained ZIP.
.PARAMETER Version
Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag
or __init__.py if omitted.
.PARAMETER PythonVersion
Embedded Python version to download. Default: 3.11.9
.PARAMETER SkipFrontend
Skip npm ci + npm run build (use if frontend is already built).
.PARAMETER SkipPerf
Skip installing optional [perf] extras (dxcam, bettercam, windows-capture).
.EXAMPLE
.\build-dist.ps1
.\build-dist.ps1 -Version "0.2.0"
.\build-dist.ps1 -SkipFrontend -SkipPerf
#>
param(
[string]$Version = "",
[string]$PythonVersion = "3.11.9",
[switch]$SkipFrontend,
[switch]$SkipPerf
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # faster downloads
$ScriptRoot = $PSScriptRoot
$BuildDir = Join-Path $ScriptRoot "build"
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $ScriptRoot "server"
$PythonDir = Join-Path $DistDir "python"
$AppDir = Join-Path $DistDir "app"
# ── Version detection ──────────────────────────────────────────
if (-not $Version) {
# Try git tag
try {
$gitTag = git describe --tags --exact-match 2>$null
if ($gitTag) { $Version = $gitTag }
} catch {}
}
if (-not $Version) {
# Try env var (CI)
if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME }
elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME }
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
if (-not $Version) { $Version = "0.0.0" }
# Strip leading 'v' for filenames
$VersionClean = $Version -replace '^v', ''
$ZipName = "LedGrab-v${VersionClean}-win-x64.zip"
Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan
Write-Host " Python: $PythonVersion"
Write-Host " Output: build\$ZipName"
Write-Host ""
# ── Clean ──────────────────────────────────────────────────────
if (Test-Path $DistDir) {
Write-Host "[1/8] Cleaning previous build..."
Remove-Item -Recurse -Force $DistDir
}
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
# ── Download embedded Python ───────────────────────────────────
$PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip"
$PythonZipPath = Join-Path $BuildDir "python-embed.zip"
Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..."
if (-not (Test-Path $PythonZipPath)) {
Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath
}
Write-Host " Extracting to python/..."
Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force
# ── Patch ._pth to enable site-packages ────────────────────────
Write-Host "[3/8] Patching Python path configuration..."
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1
if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" }
$pthContent = Get-Content $pthFile.FullName -Raw
# Uncomment 'import site'
$pthContent = $pthContent -replace '#\s*import site', 'import site'
# Add Lib\site-packages if not present
if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline
Write-Host " Patched $($pthFile.Name)"
# ── Install pip ────────────────────────────────────────────────
Write-Host "[4/8] Installing pip..."
$GetPipPath = Join-Path $BuildDir "get-pip.py"
if (-not (Test-Path $GetPipPath)) {
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath
}
$python = Join-Path $PythonDir "python.exe"
$ErrorActionPreference = 'Continue'
& $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
# ── Install dependencies ──────────────────────────────────────
Write-Host "[5/8] Installing dependencies..."
$extras = "camera,notifications"
if (-not $SkipPerf) { $extras += ",perf" }
# Install the project (pulls all deps via pyproject.toml), then remove
# the installed package itself — PYTHONPATH handles app code loading.
$ErrorActionPreference = 'Continue'
& $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object {
if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red }
}
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Build frontend ─────────────────────────────────────────────
if (-not $SkipFrontend) {
Write-Host "[6/8] Building frontend bundle..."
Push-Location $ServerDir
try {
$ErrorActionPreference = 'Continue'
& npm ci --loglevel error 2>&1 | Out-Null
& npm run build 2>&1 | ForEach-Object {
$line = "$_"
if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" }
}
$ErrorActionPreference = 'Stop'
} finally {
Pop-Location
}
} else {
Write-Host "[6/8] Skipping frontend build (--SkipFrontend)"
}
# ── Copy application files ─────────────────────────────────────
Write-Host "[7/8] Copying application files..."
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
# Copy source code (includes static/dist bundle, templates, locales)
$srcDest = Join-Path $AppDir "src"
Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse
# Copy config
$configDest = Join-Path $AppDir "config"
Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse
# Create empty data/ and logs/ directories
New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
# Clean up source maps and __pycache__ from app code
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
$launcherContent = @'
@echo off
title LedGrab v%VERSION%
cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
echo.
echo =============================================
echo LedGrab v%VERSION%
echo Open http://localhost:8080 in your browser
echo =============================================
echo.
:: Start the server (open browser after short delay)
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
pause
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
$launcherPath = Join-Path $DistDir "LedGrab.bat"
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
# ── Create ZIP ─────────────────────────────────────────────────
$ZipPath = Join-Path $BuildDir $ZipName
if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
Write-Host ""
Write-Host "Creating $ZipName..." -ForegroundColor Cyan
# Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive
$7z = Get-Command 7z -ErrorAction SilentlyContinue
if ($7z) {
& 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3
} else {
Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
}
$zipSize = (Get-Item $ZipPath).Length / 1MB
Write-Host ""
Write-Host "=== Build complete ===" -ForegroundColor Green
Write-Host " Archive: $ZipPath"
Write-Host " Size: $([math]::Round($zipSize, 1)) MB"
Write-Host ""

View File

@@ -151,6 +151,24 @@ The app has an interactive tutorial system (`static/js/features/tutorials.js`) w
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`). When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
## Icons
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
- Icon SVG paths are defined in `static/js/core/icon-paths.js` (Lucide icons, 24×24 viewBox)
- Icon constants are exported from `static/js/core/icons.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths
When you need a new icon:
1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
3. Add a corresponding `ICON_*` constant in `icons.js` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module
Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off).
For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16.
## Localization (i18n) ## Localization (i18n)
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`). **Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).

View File

@@ -114,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
} }
# Track target and scene IDs to detect changes # Track target and scene IDs to detect changes
initial_target_ids = set( known_target_ids = set(
coordinator.data.get("targets", {}).keys() if coordinator.data else [] coordinator.data.get("targets", {}).keys() if coordinator.data else []
) )
initial_scene_ids = set( known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else []) p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
) )
def _on_coordinator_update() -> None: def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes.""" """Manage WS connections and detect target list changes."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data: if not coordinator.data:
return return
@@ -134,8 +136,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
state = target_data.get("state") or {} state = target_data.get("state") or {}
if info.get("target_type") == TARGET_TYPE_KEY_COLORS: if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
if state.get("processing"): if state.get("processing"):
if target_id not in ws_manager._connections:
hass.async_create_task(ws_manager.start_listening(target_id)) hass.async_create_task(ws_manager.start_listening(target_id))
else: else:
if target_id in ws_manager._connections:
hass.async_create_task(ws_manager.stop_listening(target_id)) hass.async_create_task(ws_manager.stop_listening(target_id))
# Reload if target or scene list changed # Reload if target or scene list changed
@@ -143,7 +147,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
current_scene_ids = set( current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", []) p["id"] for p in coordinator.data.get("scene_presets", [])
) )
if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids: if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration") _LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id) hass.config_entries.async_reload(entry.entry_id)
@@ -156,11 +162,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle the set_leds service call.""" """Handle the set_leds service call."""
source_id = call.data["source_id"] source_id = call.data["source_id"]
segments = call.data["segments"] segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values(): for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR) coord = entry_data.get(DATA_COORDINATOR)
if coord: if not coord or not coord.data:
continue
source_ids = {
s["id"] for s in coord.data.get("css_sources", [])
}
if source_id in source_ids:
await coord.push_segments(source_id, segments) await coord.push_segments(source_id, segments)
break return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"): if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register( hass.services.async_register(
@@ -188,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok return unload_ok

View File

@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Return if entity is available.""" """Return if entity is available."""
if not self.coordinator.data: if not self.coordinator.data:
return False return False
return any( return self._preset_id in {
p["id"] == self._preset_id p["id"] for p in self.coordinator.data.get("scene_presets", [])
for p in self.coordinator.data.get("scene_presets", []) }
)
async def async_press(self) -> None: async def async_press(self) -> None:
"""Activate the scene preset.""" """Activate the scene preset."""

View File

@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.api_key = api_key self.api_key = api_key
self.server_version = "unknown" self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"} self._auth_headers = {"Authorization": f"Bearer {api_key}"}
self._pattern_cache: dict[str, list[dict]] = {} self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__( super().__init__(
hass, hass,
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
kc_settings = target.get("key_colors_settings") or {} kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "") template_id = kc_settings.get("pattern_template_id", "")
if template_id: if template_id:
result["rectangles"] = await self._get_rectangles( result["rectangles"] = await self._fetch_rectangles(
template_id template_id
) )
else: else:
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/health", f"{self.server_url}/health",
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets", f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state", f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics", f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def _get_rectangles(self, template_id: str) -> list[dict]: async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Get rectangles for a pattern template, using cache.""" """Fetch rectangles for a pattern template (no cache — always fresh)."""
if template_id in self._pattern_cache:
return self._pattern_cache[template_id]
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}", f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
rectangles = data.get("rectangles", []) return data.get("rectangles", [])
self._pattern_cache[template_id] = rectangles
return rectangles
except Exception as err: except Exception as err:
_LOGGER.warning( _LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err "Failed to fetch pattern template %s: %s", template_id, err
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/devices", f"{self.server_url}/api/v1/devices",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch devices: %s", err) _LOGGER.warning("Failed to fetch devices: %s", err)
return {} return {}
devices_data: dict[str, dict[str, Any]] = {} # Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
for device in devices:
device_id = device["id"] device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None} entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []): if "brightness_control" in (device.get("capabilities") or []):
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness", f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
bri_data = await resp.json() bri_data = await resp.json()
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to fetch brightness for device %s: %s", "Failed to fetch brightness for device %s: %s",
device_id, err, device_id, err,
) )
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry devices_data[device_id] = entry
return devices_data return devices_data
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/brightness", f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness}, json={"brightness": brightness},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/color", f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color}, json={"color": color},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/output-targets/{target_id}", f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}}, json={"key_colors_settings": {"brightness": brightness_float}},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources", f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/value-sources", f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/scene-presets", f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -342,7 +347,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors}, json={"colors": colors},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status not in (200, 204): if resp.status not in (200, 204):
body = await resp.text() body = await resp.text()
@@ -358,7 +363,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments}, json={"segments": segments},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status not in (200, 204): if resp.status not in (200, 204):
body = await resp.text() body = await resp.text()
@@ -373,7 +378,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate", f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -390,7 +395,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}", f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs, json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -398,14 +403,15 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to update source %s: %s %s", "Failed to update source %s: %s %s",
source_id, resp.status, body, source_id, resp.status, body,
) )
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None: async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update a output target's fields.""" """Update an output target's fields."""
async with self.session.put( async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}", f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs, json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -421,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start", f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 409: if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id) _LOGGER.debug("Target %s already processing", target_id)
@@ -439,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop", f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 409: if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id) _LOGGER.debug("Target %s already stopped", target_id)

View File

@@ -63,9 +63,13 @@ class ApiInputLight(CoordinatorEntity, LightEntity):
self._entry_id = entry_id self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light" self._attr_unique_id = f"{self._source_id}_light"
# Local state — not derived from coordinator data # Restore state from fallback_color
self._is_on: bool = False fallback = self._get_fallback_color()
self._rgb_color: tuple[int, int, int] = (255, 255, 255) is_off = fallback == [0, 0, 0]
self._is_on: bool = not is_off
self._rgb_color: tuple[int, int, int] = (
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
)
self._brightness: int = 255 self._brightness: int = 255
@property @property

View File

@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
return self._target_id in self.coordinator.data.get("targets", {}) return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id(option) source_id = self._name_to_id_map().get(option)
if source_id is None: if source_id is None:
_LOGGER.error("CSS source not found: %s", option) _LOGGER.error("CSS source not found: %s", option)
return return
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
self._target_id, color_strip_source_id=source_id self._target_id, color_strip_source_id=source_id
) )
def _name_to_id(self, name: str) -> str | None: def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or [] sources = (self.coordinator.data or {}).get("css_sources") or []
for s in sources: return {s["name"]: s["id"] for s in sources}
if s["name"] == name:
return s["id"]
return None
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if option == NONE_OPTION: if option == NONE_OPTION:
source_id = "" source_id = ""
else: else:
source_id = self._name_to_id(option) name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None: if source_id is None:
_LOGGER.error("Value source not found: %s", option) _LOGGER.error("Value source not found: %s", option)
return return
await self.coordinator.update_target( await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id self._target_id, brightness_value_source_id=source_id
) )
def _name_to_id(self, name: str) -> str | None:
sources = (self.coordinator.data or {}).get("value_sources") or []
for s in sources:
if s["name"] == name:
return s["id"]
return None

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Processing" "name": "Processing"
@@ -58,9 +63,12 @@
"name": "Brightness" "name": "Brightness"
} }
}, },
"light": { "select": {
"light": { "color_strip_source": {
"name": "Light" "name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
} }
} }
} }

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Обработка" "name": "Обработка"
@@ -58,9 +63,12 @@
"name": "Яркость" "name": "Яркость"
} }
}, },
"light": { "select": {
"light": { "color_strip_source": {
"name": "Подсветка" "name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
} }
} }
} }

View File

@@ -7,7 +7,7 @@ const watch = process.argv.includes('--watch');
/** @type {esbuild.BuildOptions} */ /** @type {esbuild.BuildOptions} */
const jsOpts = { const jsOpts = {
entryPoints: [`${srcDir}/js/app.js`], entryPoints: [`${srcDir}/js/app.ts`],
bundle: true, bundle: true,
format: 'iife', format: 'iife',
outfile: `${outDir}/app.bundle.js`, outfile: `${outDir}/app.bundle.js`,

View File

@@ -13,7 +13,8 @@
"elkjs": "^0.11.1" "elkjs": "^0.11.1"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4",
"typescript": "^5.9.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -493,6 +494,19 @@
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.27.4"
} }
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
} }
}, },
"dependencies": { "dependencies": {
@@ -729,6 +743,12 @@
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.27.4"
} }
},
"typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true
} }
} }
} }

View File

@@ -9,13 +9,15 @@
}, },
"scripts": { "scripts": {
"build": "node esbuild.mjs", "build": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch" "watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4",
"typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.1", "chart.js": "^4.5.1",

View File

@@ -24,6 +24,7 @@ from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -42,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
audio_source_id=getattr(source, "audio_source_id", None), audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None), channel=getattr(source, "channel", None),
description=source.description, description=source.description,
tags=getattr(source, 'tags', []), tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -85,6 +86,9 @@ async def create_audio_source(
) )
fire_entity_event("audio_source", "created", source.id) fire_entity_event("audio_source", "created", source.id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -125,6 +129,9 @@ async def update_audio_source(
) )
fire_entity_event("audio_source", "updated", source_id) fire_entity_event("audio_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -148,6 +155,9 @@ async def delete_audio_source(
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id) fire_entity_event("audio_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -19,6 +19,7 @@ from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -38,7 +39,7 @@ async def list_audio_templates(
responses = [ responses = [
AudioTemplateResponse( AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []), engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, updated_at=t.updated_at, description=t.description,
) )
@@ -66,10 +67,13 @@ async def create_audio_template(
fire_entity_event("audio_template", "created", template.id) fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type, id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, tags=getattr(template, 'tags', []), engine_config=template.engine_config, tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, description=template.description, updated_at=template.updated_at, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -90,7 +94,7 @@ async def get_audio_template(
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found") raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []), engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, updated_at=t.updated_at, description=t.description,
) )
@@ -113,10 +117,13 @@ async def update_audio_template(
fire_entity_event("audio_template", "updated", template_id) fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []), engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, updated_at=t.updated_at, description=t.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -137,6 +144,9 @@ async def delete_audio_template(
fire_entity_event("audio_template", "deleted", template_id) fire_entity_event("audio_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -33,6 +33,7 @@ from wled_controller.storage.automation import (
from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -113,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
is_active=state["is_active"], is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=getattr(automation, 'tags', []), tags=automation.tags,
created_at=automation.created_at, created_at=automation.created_at,
updated_at=automation.updated_at, updated_at=automation.updated_at,
) )
@@ -163,6 +164,9 @@ async def create_automation(
try: try:
conditions = [_condition_from_schema(c) for c in data.conditions] conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -249,6 +253,9 @@ async def update_automation(
if data.conditions is not None: if data.conditions is not None:
try: try:
conditions = [_condition_from_schema(c) for c in data.conditions] conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -28,6 +28,7 @@ from wled_controller.storage.color_strip_processing_template_store import ColorS
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -43,7 +44,7 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=getattr(t, 'tags', []), tags=t.tags,
) )
@@ -79,6 +80,9 @@ async def create_cspt(
) )
fire_entity_event("cspt", "created", template.id) fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template) return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -119,6 +123,9 @@ async def update_cspt(
) )
fire_entity_event("cspt", "updated", template_id) fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template) return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -148,6 +155,9 @@ async def delete_cspt(
fire_entity_event("cspt", "deleted", template_id) fire_entity_event("cspt", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -42,6 +42,7 @@ from wled_controller.storage.picture_source import ProcessedPictureSource, Scree
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -154,6 +155,10 @@ async def create_color_strip_source(
fire_entity_event("color_strip_source", "created", source.id) fire_entity_event("color_strip_source", "created", source.id)
return _css_to_response(source) return _css_to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -30,6 +30,7 @@ from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _device_to_response(device) -> DeviceResponse:
rgbw=device.rgbw, rgbw=device.rgbw,
zone_mode=device.zone_mode, zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)), capabilities=sorted(get_device_capabilities(device.device_type)),
tags=getattr(device, 'tags', []), tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'), dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0), dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1), dmx_start_channel=getattr(device, 'dmx_start_channel', 1),

View File

@@ -59,6 +59,7 @@ from wled_controller.storage.key_colors_output_target import (
) )
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -106,7 +107,7 @@ def _target_to_response(target) -> OutputTargetResponse:
adaptive_fps=target.adaptive_fps, adaptive_fps=target.adaptive_fps,
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -119,7 +120,7 @@ def _target_to_response(target) -> OutputTargetResponse:
picture_source_id=target.picture_source_id, picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings), key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -130,7 +131,7 @@ def _target_to_response(target) -> OutputTargetResponse:
name=target.name, name=target.name,
target_type=target.target_type, target_type=target.target_type,
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -188,6 +189,9 @@ async def create_target(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -598,6 +602,9 @@ async def test_kc_target(
try: try:
chain = source_store.resolve_stream_chain(target.picture_source_id) chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -735,6 +742,9 @@ async def test_kc_target(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:
@@ -849,59 +859,42 @@ async def test_kc_target_ws(
await websocket.accept() await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})") logger.info(f"KC test WS connected for {target_id} (fps={fps})")
capture_stream = None # Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try: try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True: while True:
loop_start = time.monotonic() loop_start = time.monotonic()
pil_image = None
capture_stream_local = None
try: try:
import httpx capture = await asyncio.to_thread(live_stream.get_latest_frame)
# Reload chain each iteration for dynamic sources
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource): if capture is None or capture.image is None:
source = raw_stream.image_source await asyncio.sleep(frame_interval)
if source.startswith(("http://", "https://")): continue
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if path.exists():
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource): # Skip if same frame object (no new capture yet)
try: if capture is prev_frame_ref:
capture_tmpl = template_store_inst.get_template(raw_stream.capture_template_id) await asyncio.sleep(frame_interval * 0.5)
except ValueError: continue
break prev_frame_ref = capture
if capture_tmpl.engine_type not in EngineRegistry.get_available_engines():
break
capture_stream_local = EngineRegistry.create_stream(
capture_tmpl.engine_type, raw_stream.display_index, capture_tmpl.engine_config
)
capture_stream_local.initialize()
screen_capture = capture_stream_local.capture_frame()
if screen_capture is not None and isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
# VideoCaptureSource or other — not directly supported in WS test
break
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
if pil_image is None: if pil_image is None:
await asyncio.sleep(frame_interval) await asyncio.sleep(frame_interval)
continue continue
# Apply postprocessing # Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", []) pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst: if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image) img_array = np.array(pil_image)
@@ -971,12 +964,6 @@ async def test_kc_target_ws(
if isinstance(inner_e, WebSocketDisconnect): if isinstance(inner_e, WebSocketDisconnect):
raise raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}") logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
finally:
if capture_stream_local:
try:
capture_stream_local.cleanup()
except Exception:
pass
elapsed = time.monotonic() - loop_start elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed sleep_time = frame_interval - elapsed
@@ -988,9 +975,11 @@ async def test_kc_target_ws(
except Exception as e: except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True) logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally: finally:
if capture_stream: if live_stream is not None:
try: try:
capture_stream.cleanup() await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception: except Exception:
pass pass
logger.info(f"KC test WS closed for {target_id}") logger.info(f"KC test WS closed for {target_id}")

View File

@@ -19,6 +19,7 @@ from wled_controller.storage.key_colors_output_target import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -37,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=getattr(t, 'tags', []), tags=t.tags,
) )
@@ -76,6 +77,9 @@ async def create_pattern_template(
) )
fire_entity_event("pattern_template", "created", template.id) fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -121,6 +125,9 @@ async def update_pattern_template(
) )
fire_entity_event("pattern_template", "updated", template_id) fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -149,6 +156,9 @@ async def delete_pattern_template(
fire_entity_event("pattern_template", "deleted", template_id) fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -41,6 +41,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -62,7 +63,7 @@ def _stream_to_response(s) -> PictureSourceResponse:
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
description=s.description, description=s.description,
tags=getattr(s, 'tags', []), tags=s.tags,
# Video fields # Video fields
url=getattr(s, "url", None), url=getattr(s, "url", None),
loop=getattr(s, "loop", None), loop=getattr(s, "loop", None),
@@ -228,6 +229,9 @@ async def create_picture_source(
return _stream_to_response(stream) return _stream_to_response(stream)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -280,6 +284,9 @@ async def update_picture_source(
) )
fire_entity_event("picture_source", "updated", stream_id) fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream) return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -309,6 +316,9 @@ async def delete_picture_source(
fire_entity_event("picture_source", "deleted", stream_id) fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -383,6 +393,9 @@ async def test_picture_source(
# Resolve stream chain # Resolve stream chain
try: try:
chain = store.resolve_stream_chain(stream_id) chain = store.resolve_stream_chain(stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -541,6 +554,9 @@ async def test_picture_source(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:

View File

@@ -36,6 +36,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
tags=getattr(t, 'tags', []), tags=t.tags,
) )
@@ -61,13 +62,9 @@ async def list_pp_templates(
store: PostprocessingTemplateStore = Depends(get_pp_template_store), store: PostprocessingTemplateStore = Depends(get_pp_template_store),
): ):
"""List all postprocessing templates.""" """List all postprocessing templates."""
try:
templates = store.get_all_templates() templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates] responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses)) return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list postprocessing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201) @router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
@@ -87,6 +84,9 @@ async def create_pp_template(
) )
fire_entity_event("pp_template", "created", template.id) fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -127,6 +127,9 @@ async def update_pp_template(
) )
fire_entity_event("pp_template", "updated", template_id) fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -156,6 +159,9 @@ async def delete_pp_template(
fire_entity_event("pp_template", "deleted", template_id) fire_entity_event("pp_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -184,6 +190,9 @@ async def test_pp_template(
# Resolve source stream chain to get the raw stream # Resolve source stream chain to get the raw stream
try: try:
chain = stream_store.resolve_stream_chain(test_request.source_stream_id) chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -327,6 +336,9 @@ async def test_pp_template(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -28,6 +28,7 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -46,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"fps": t.fps, "fps": t.fps,
} for t in preset.targets], } for t in preset.targets],
order=preset.order, order=preset.order,
tags=getattr(preset, 'tags', []), tags=preset.tags,
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
) )
@@ -85,6 +86,9 @@ async def create_scene_preset(
try: try:
preset = store.create_preset(preset) preset = store.create_preset(preset)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -20,6 +20,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -34,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
name=clock.name, name=clock.name,
speed=rt.speed if rt else clock.speed, speed=rt.speed if rt else clock.speed,
description=clock.description, description=clock.description,
tags=getattr(clock, 'tags', []), tags=clock.tags,
is_running=rt.is_running if rt else True, is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0, elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at, created_at=clock.created_at,
@@ -73,6 +74,9 @@ async def create_sync_clock(
) )
fire_entity_event("sync_clock", "created", clock.id) fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager) return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -114,6 +118,9 @@ async def update_sync_clock(
manager.update_speed(clock_id, clock.speed) manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id) fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -137,6 +144,9 @@ async def delete_sync_clock(
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id) fire_entity_event("sync_clock", "deleted", clock_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -68,6 +68,7 @@ psutil.cpu_percent(interval=None)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history) # GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
def _get_cpu_name() -> str | None: def _get_cpu_name() -> str | None:
@@ -96,8 +97,8 @@ def _get_cpu_name() -> str | None:
.decode() .decode()
.strip() .strip()
) )
except Exception: except Exception as e:
pass logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None return platform.processor() or None
@@ -157,7 +158,7 @@ async def list_all_tags(_: AuthRequired):
items = fn() if fn else None items = fn() if fn else None
if items: if items:
for item in items: for item in items:
all_tags.update(getattr(item, 'tags', [])) all_tags.update(item.tags)
return {"tags": sorted(all_tags)} return {"tags": sorted(all_tags)}
@@ -205,6 +206,10 @@ async def get_displays(
count=len(displays), count=len(displays),
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -265,8 +270,8 @@ def get_system_performance(_: AuthRequired):
memory_total_mb=round(mem_info.total / 1024 / 1024, 1), memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp), temperature_c=float(temp),
) )
except Exception: except Exception as e:
pass logger.debug("NVML query failed: %s", e)
return PerformanceResponse( return PerformanceResponse(
cpu_name=_cpu_name, cpu_name=_cpu_name,

View File

@@ -41,6 +41,7 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -64,7 +65,7 @@ async def list_templates(
name=t.name, name=t.name,
engine_type=t.engine_type, engine_type=t.engine_type,
engine_config=t.engine_config, engine_config=t.engine_config,
tags=getattr(t, 'tags', []), tags=t.tags,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
@@ -104,12 +105,16 @@ async def create_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -134,7 +139,7 @@ async def get_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
@@ -165,12 +170,16 @@ async def update_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -210,6 +219,9 @@ async def delete_template(
except HTTPException: except HTTPException:
raise # Re-raise HTTP exceptions as-is raise # Re-raise HTTP exceptions as-is
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -359,6 +371,10 @@ def test_template(
), ),
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:

View File

@@ -23,6 +23,7 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -105,6 +106,9 @@ async def create_value_source(
) )
fire_entity_event("value_source", "created", source.id) fire_entity_event("value_source", "created", source.id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,9 @@ async def update_value_source(
pm.update_value_source(source_id) pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id) fire_entity_event("value_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -182,6 +189,9 @@ async def delete_value_source(
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id) fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -93,7 +93,7 @@ class Config(BaseSettings):
if not config_path.exists(): if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}") raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, "r") as f: with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f) config_data = yaml.safe_load(f)
return cls(**config_data) return cls(**config_data)

View File

@@ -398,8 +398,8 @@ class AutomationEngine:
"automation_id": automation_id, "automation_id": automation_id,
"action": action, "action": action,
}) })
except Exception: except Exception as e:
pass logger.error("Automation action failed: %s", e, exc_info=True)
# ===== Public query methods (used by API) ===== # ===== Public query methods (used by API) =====

View File

@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
else: else:
arr = np.array(pixels, dtype=np.uint16) arr = np.array(pixels, dtype=np.uint16)
if brightness < 255: # Note: brightness already applied by processor loop (_cached_brightness)
arr = arr * brightness // 255
np.clip(arr, 0, 255, out=arr) np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes() rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes return self._header + rgb_bytes

View File

@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
else: else:
arr = np.array(pixels, dtype=np.uint16) arr = np.array(pixels, dtype=np.uint16)
if brightness < 255: # Note: brightness already applied by processor loop (_cached_brightness)
arr = arr * brightness // 255
# Clamp to 0250: values >250 are command bytes in AmbiLED protocol # Clamp to 0250: values >250 are command bytes in AmbiLED protocol
np.clip(arr, 0, 250, out=arr) np.clip(arr, 0, 250, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes() rgb_bytes = arr.astype(np.uint8).tobytes()

View File

@@ -145,7 +145,7 @@ class ChromaClient(LEDClient):
else: else:
pixel_arr = np.array(pixels, dtype=np.uint8) pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0 # Note: brightness already applied by processor loop (_cached_brightness)
device_info = CHROMA_DEVICES.get(self._chroma_device_type) device_info = CHROMA_DEVICES.get(self._chroma_device_type)
if not device_info: if not device_info:
return False return False
@@ -156,10 +156,7 @@ class ChromaClient(LEDClient):
# Chroma uses BGR packed as 0x00BBGGRR integers # Chroma uses BGR packed as 0x00BBGGRR integers
colors = [] colors = []
for i in range(n): for i in range(n):
r, g, b = pixel_arr[i] r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
r = int(r * bri_scale)
g = int(g * bri_scale)
b = int(b * bri_scale)
colors.append(r | (g << 8) | (b << 16)) colors.append(r | (g << 8) | (b << 16))
# Pad to max_leds if needed # Pad to max_leds if needed

View File

@@ -115,7 +115,8 @@ class ESPNowClient(LEDClient):
else: else:
pixel_bytes = bytes(c for rgb in pixels for c in rgb) pixel_bytes = bytes(c for rgb in pixels for c in rgb)
frame = _build_frame(self._peer_mac, pixel_bytes, brightness) # Note: brightness already applied by processor loop; pass 255 to firmware
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
try: try:
self._serial.write(frame) self._serial.write(frame)
except Exception as e: except Exception as e:

View File

@@ -187,7 +187,7 @@ class GameSenseClient(LEDClient):
else: else:
pixel_arr = np.array(pixels, dtype=np.uint8) pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0 # Note: brightness already applied by processor loop (_cached_brightness)
# Use average color for single-zone devices, or first N for multi-zone # Use average color for single-zone devices, or first N for multi-zone
if len(pixel_arr) == 0: if len(pixel_arr) == 0:
@@ -195,9 +195,9 @@ class GameSenseClient(LEDClient):
# Compute average color for the zone # Compute average color for the zone
avg = pixel_arr.mean(axis=0) avg = pixel_arr.mean(axis=0)
r = int(avg[0] * bri_scale) r = int(avg[0])
g = int(avg[1] * bri_scale) g = int(avg[1])
b = int(avg[2] * bri_scale) b = int(avg[2])
event_data = { event_data = {
"game": GAME_NAME, "game": GAME_NAME,

View File

@@ -46,13 +46,13 @@ def _build_entertainment_frame(
header[15] = 0x00 # reserved header[15] = 0x00 # reserved
# Light data # Light data
bri_scale = brightness / 255.0 # Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray() data = bytearray()
for idx, (r, g, b) in enumerate(lights): for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group light_id = idx # 0-based light index in entertainment group
r16 = int(r * bri_scale * 257) # scale 0-255 to 0-65535 r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * bri_scale * 257) g16 = int(g * 257)
b16 = int(b * bri_scale * 257) b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16) data += struct.pack(">BHHH", light_id, r16, g16, b16)
return bytes(header) + bytes(data) return bytes(header) + bytes(data)

View File

@@ -302,9 +302,7 @@ class OpenRGBLEDClient(LEDClient):
return return
self._last_sent_pixels = pixel_array.copy() self._last_sent_pixels = pixel_array.copy()
# Apply brightness scaling after dedup # Note: brightness already applied by processor loop (_cached_brightness)
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Separate mode: resample full pixel array independently per zone # Separate mode: resample full pixel array independently per zone
if self._zone_mode == "separate" and len(self._target_zones) > 1: if self._zone_mode == "separate" and len(self._target_zones) > 1:

View File

@@ -162,7 +162,7 @@ class SPIClient(LEDClient):
if not self._connected: if not self._connected:
return return
bri_scale = brightness / 255.0 # Note: brightness already applied by processor loop (_cached_brightness)
if isinstance(pixels, np.ndarray): if isinstance(pixels, np.ndarray):
pixel_arr = pixels pixel_arr = pixels
@@ -176,7 +176,7 @@ class SPIClient(LEDClient):
except ImportError: except ImportError:
return return
self._strip.setBrightness(brightness) self._strip.setBrightness(255)
for i in range(min(len(pixel_arr), self._led_count)): for i in range(min(len(pixel_arr), self._led_count)):
r, g, b = pixel_arr[i] r, g, b = pixel_arr[i]
self._strip.setPixelColor(i, Color(int(r), int(g), int(b))) self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
@@ -185,7 +185,7 @@ class SPIClient(LEDClient):
elif self._spi: elif self._spi:
# SPI bitbang path: convert RGB to WS2812 wire format # SPI bitbang path: convert RGB to WS2812 wire format
# Each bit is encoded as 3 SPI bits: 1=110, 0=100 # Each bit is encoded as 3 SPI bits: 1=110, 0=100
scaled = (pixel_arr[:self._led_count].astype(np.float32) * bri_scale).astype(np.uint8) scaled = pixel_arr[:self._led_count]
# GRB order for WS2812 # GRB order for WS2812
grb = scaled[:, [1, 0, 2]] grb = scaled[:, [1, 0, 2]]
raw_bytes = grb.tobytes() raw_bytes = grb.tobytes()

View File

@@ -100,7 +100,7 @@ class USBHIDClient(LEDClient):
else: else:
pixel_list = list(pixels) pixel_list = list(pixels)
bri_scale = brightness / 255.0 # Note: brightness already applied by processor loop (_cached_brightness)
# Build HID reports — split across multiple reports if needed # Build HID reports — split across multiple reports if needed
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...] # Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
@@ -119,9 +119,9 @@ class USBHIDClient(LEDClient):
for i, (r, g, b) in enumerate(chunk): for i, (r, g, b) in enumerate(chunk):
base = 5 + i * 3 base = 5 + i * 3
report[base] = int(r * bri_scale) report[base] = int(r)
report[base + 1] = int(g * bri_scale) report[base + 1] = int(g)
report[base + 2] = int(b * bri_scale) report[base + 2] = int(b)
reports.append(bytes(report)) reports.append(bytes(report))
offset += len(chunk) offset += len(chunk)

View File

@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
True if successful True if successful
""" """
try: try:
if brightness < 255: # Note: brightness already applied by processor loop (_cached_brightness)
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
logger.debug(f"Sending {len(pixels)} LEDs via DDP") logger.debug(f"Sending {len(pixels)} LEDs via DDP")
self._ddp_client.send_pixels_numpy(pixels) self._ddp_client.send_pixels_numpy(pixels)
logger.debug(f"Successfully sent pixel colors via DDP") logger.debug(f"Successfully sent pixel colors via DDP")
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
# Build WLED JSON state # Build WLED JSON state
payload = { payload = {
"on": True, "on": True,
"bri": int(brightness), "bri": 255, # brightness already applied by processor loop
"seg": [ "seg": [
{ {
"id": segment_id, "id": segment_id,
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
else: else:
pixel_array = np.array(pixels, dtype=np.uint8) pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255: # Note: brightness already applied by processor loop (_cached_brightness)
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
self._ddp_client.send_pixels_numpy(pixel_array) self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods ===== # ===== LEDClient abstraction methods =====

View File

@@ -92,7 +92,11 @@ class ApiInputColorStripStream(ColorStripStream):
if n > self._led_count: if n > self._led_count:
self._ensure_capacity(n) self._ensure_capacity(n)
if n == self._led_count: if n == self._led_count:
self._colors = colors.astype(np.uint8) if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe')
else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
elif n < self._led_count: elif n < self._led_count:
# Zero-pad to led_count # Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8) padded = np.zeros((self._led_count, 3), dtype=np.uint8)

View File

@@ -48,12 +48,22 @@ class CompositeColorStripStream(ColorStripStream):
self._latest_colors: Optional[np.ndarray] = None self._latest_colors: Optional[np.ndarray] = None
self._latest_layer_colors: Optional[List[np.ndarray]] = None self._latest_layer_colors: Optional[List[np.ndarray]] = None
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# layer_index -> (source_id, consumer_id, stream) # layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {} self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream) # layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {} self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
self._sub_snapshot_version: int = -1 # version of cached snapshot
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
# Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = {
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes) # Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0 self._pool_n = 0
@@ -111,6 +121,7 @@ class CompositeColorStripStream(ColorStripStream):
def get_layer_colors(self) -> Optional[List[np.ndarray]]: def get_layer_colors(self) -> Optional[List[np.ndarray]]:
"""Return per-layer color snapshots (after resize/brightness, before blending).""" """Return per-layer color snapshots (after resize/brightness, before blending)."""
self._need_layer_snapshots = True
with self._colors_lock: with self._colors_lock:
return self._latest_layer_colors return self._latest_layer_colors
@@ -165,6 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
# ── Sub-stream lifecycle ──────────────────────────────────── # ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None: def _acquire_sub_streams(self) -> None:
self._sub_streams_version += 1
for i, layer in enumerate(self._layers): for i, layer in enumerate(self._layers):
if not layer.get("enabled", True): if not layer.get("enabled", True):
continue continue
@@ -193,6 +205,7 @@ class CompositeColorStripStream(ColorStripStream):
) )
def _release_sub_streams(self) -> None: def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()): for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try: try:
self._css_manager.release(src_id, consumer_id) self._css_manager.release(src_id, consumer_id)
@@ -356,7 +369,10 @@ class CompositeColorStripStream(ColorStripStream):
layer_snapshots: List[np.ndarray] = [] layer_snapshots: List[np.ndarray] = []
with self._sub_lock: with self._sub_lock:
sub_snapshot = dict(self._sub_streams) if self._sub_streams_version != self._sub_snapshot_version:
self._sub_snapshot_cache = dict(self._sub_streams)
self._sub_snapshot_version = self._sub_streams_version
sub_snapshot = self._sub_snapshot_cache
for i, layer in enumerate(self._layers): for i, layer in enumerate(self._layers):
if not layer.get("enabled", True): if not layer.get("enabled", True):
@@ -412,6 +428,7 @@ class CompositeColorStripStream(ColorStripStream):
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8) colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
# Snapshot layer colors before blending (copy — may alias shared buf) # Snapshot layer colors before blending (copy — may alias shared buf)
if self._need_layer_snapshots:
layer_snapshots.append(colors.copy()) layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0) opacity = layer.get("opacity", 1.0)
@@ -425,11 +442,11 @@ class CompositeColorStripStream(ColorStripStream):
result_buf[:] = colors result_buf[:] = colors
else: else:
result_buf[:] = 0 result_buf[:] = 0
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal")) blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf) blend_fn(result_buf, colors, alpha, result_buf)
has_result = True has_result = True
else: else:
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal")) blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf) blend_fn(result_buf, colors, alpha, result_buf)
if has_result: if has_result:

View File

@@ -91,7 +91,8 @@ class MetricsHistory:
# Per-target metrics from processor states # Per-target metrics from processor states
try: try:
all_states = self._manager.get_all_target_states() all_states = self._manager.get_all_target_states()
except Exception: except Exception as e:
logger.error("Failed to get target states: %s", e)
all_states = {} all_states = {}
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()

View File

@@ -43,6 +43,7 @@ class ProcessingMetrics:
errors_count: int = 0 errors_count: int = 0
last_error: Optional[str] = None last_error: Optional[str] = None
last_update: Optional[datetime] = None last_update: Optional[datetime] = None
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
start_time: Optional[datetime] = None start_time: Optional[datetime] = None
fps_actual: float = 0.0 fps_actual: float = 0.0
fps_potential: float = 0.0 fps_potential: float = 0.0

View File

@@ -22,6 +22,7 @@ from __future__ import annotations
import math import math
import time import time
from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
@@ -43,12 +44,13 @@ logger = get_logger(__name__)
# Base class # Base class
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ValueStream: class ValueStream(ABC):
"""Abstract base for runtime value streams.""" """Abstract base for runtime value streams."""
@abstractmethod
def get_value(self) -> float: def get_value(self) -> float:
"""Return current scalar value (0.01.0).""" """Return current scalar value (0.01.0)."""
return 1.0 ...
def start(self) -> None: def start(self) -> None:
"""Acquire resources (if any).""" """Acquire resources (if any)."""

View File

@@ -382,6 +382,14 @@ class WledTargetProcessor(TargetProcessor):
else: else:
total_ms = None total_ms = None
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return { return {
"target_id": self._target_id, "target_id": self._target_id,
"device_id": self._device_id, "device_id": self._device_id,
@@ -405,7 +413,7 @@ class WledTargetProcessor(TargetProcessor):
"display_index": self._resolved_display_index, "display_index": self._resolved_display_index,
"overlay_active": self._overlay_active, "overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive, "needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update, "last_update": last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "errors": [metrics.last_error] if metrics.last_error else [],
"device_streaming_reachable": self._device_reachable if self._is_running else None, "device_streaming_reachable": self._device_reachable if self._is_running else None,
"fps_effective": self._effective_fps if self._is_running else None, "fps_effective": self._effective_fps if self._is_running else None,
@@ -419,6 +427,14 @@ class WledTargetProcessor(TargetProcessor):
if metrics.start_time and self._is_running: if metrics.start_time and self._is_running:
uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds() uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return { return {
"target_id": self._target_id, "target_id": self._target_id,
"device_id": self._device_id, "device_id": self._device_id,
@@ -429,7 +445,7 @@ class WledTargetProcessor(TargetProcessor):
"frames_processed": metrics.frames_processed, "frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count, "errors_count": metrics.errors_count,
"last_error": metrics.last_error, "last_error": metrics.last_error,
"last_update": metrics.last_update, "last_update": last_update,
} }
# ----- Overlay ----- # ----- Overlay -----
@@ -578,7 +594,14 @@ class WledTargetProcessor(TargetProcessor):
keepalive_interval = self._keepalive_interval keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10) fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque() _fps_sum = 0.0
send_timestamps: collections.deque = collections.deque(maxlen=self._target_fps + 10)
def _fps_current_from_timestamps():
"""Count timestamps within the last second."""
cutoff = time.perf_counter() - 1.0
return sum(1 for ts in send_timestamps if ts > cutoff)
last_send_time = 0.0 last_send_time = 0.0
_last_preview_broadcast = 0.0 _last_preview_broadcast = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
@@ -728,7 +751,7 @@ class WledTargetProcessor(TargetProcessor):
has_any_frame = False has_any_frame = False
_diag_device_info_age += 1 _diag_device_info_age += 1
if _diag_device_info is None or _diag_device_info_age >= 30: if _diag_device_info is None or _diag_device_info_age >= 300:
_diag_device_info = self._ctx.get_device_info(self._device_id) _diag_device_info = self._ctx.get_device_info(self._device_id)
_diag_device_info_age = 0 _diag_device_info_age = 0
device_info = _diag_device_info device_info = _diag_device_info
@@ -822,9 +845,7 @@ class WledTargetProcessor(TargetProcessor):
send_timestamps.append(now) send_timestamps.append(now)
self._metrics.frames_keepalive += 1 self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < loop_start - 1.0: self._metrics.fps_current = _fps_current_from_timestamps()
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(SKIP_REPOLL) await asyncio.sleep(SKIP_REPOLL)
continue continue
@@ -849,9 +870,7 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < now - 1.0: self._metrics.fps_current = _fps_current_from_timestamps()
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
is_animated = stream.is_animated is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
@@ -888,7 +907,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.timing_send_ms = send_ms self._metrics.timing_send_ms = send_ms
self._metrics.frames_processed += 1 self._metrics.frames_processed += 1
self._metrics.last_update = datetime.now(timezone.utc) self._metrics.last_update_mono = time.monotonic()
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0: if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info( logger.info(
@@ -900,15 +919,17 @@ class WledTargetProcessor(TargetProcessor):
interval = now - prev_frame_time_stamp interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now prev_frame_time_stamp = now
if self._metrics.frames_processed > 1: if self._metrics.frames_processed > 1:
fps_samples.append(1.0 / interval if interval > 0 else 0) new_fps = 1.0 / interval if interval > 0 else 0
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples) if len(fps_samples) == fps_samples.maxlen:
_fps_sum -= fps_samples[0]
fps_samples.append(new_fps)
_fps_sum += new_fps
self._metrics.fps_actual = _fps_sum / len(fps_samples)
processing_time = now - loop_start processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
while send_timestamps and send_timestamps[0] < now - 1.0: self._metrics.fps_current = _fps_current_from_timestamps()
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e: except Exception as e:
self._metrics.errors_count += 1 self._metrics.errors_count += 1

View File

@@ -27,9 +27,18 @@
padding: 0 4px; padding: 0 4px;
} }
/* Automation condition pills need more room than the default 180px */ /* Automation condition pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
[data-automation-id] .stream-card-prop { [data-automation-id] .stream-card-prop {
max-width: 280px; max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* Automation condition editor rows */ /* Automation condition editor rows */

View File

@@ -12,11 +12,47 @@
--warning-color: #ff9800; --warning-color: #ff9800;
--info-color: #2196F3; --info-color: #2196F3;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace; --font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 20px;
--space-xl: 40px;
/* Border radius */
--radius: 8px; --radius: 8px;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--radius-pill: 100px; --radius-pill: 100px;
/* Animation timing */
--duration-fast: 0.15s;
--duration-normal: 0.25s;
--duration-slow: 0.4s;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Font weights */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Z-index layers */
--z-card-elevated: 10;
--z-sticky: 100;
--z-dropdown: 200;
--z-bulk-toolbar: 1000;
--z-modal: 2000;
--z-log-overlay: 2100;
--z-confirm: 2500;
--z-command-palette: 3000;
--z-toast: 3000;
--z-overlay-spinner: 9999;
--z-lightbox: 10000;
--z-connection: 10000;
} }
/* ── SVG icon base ── */ /* ── SVG icon base ── */
@@ -59,8 +95,8 @@
--card-bg: #ffffff; --card-bg: #ffffff;
--text-color: #333333; --text-color: #333333;
--text-primary: #333333; --text-primary: #333333;
--text-secondary: #666; --text-secondary: #595959;
--text-muted: #999; --text-muted: #767676;
--border-color: #e0e0e0; --border-color: #e0e0e0;
--display-badge-bg: rgba(255, 255, 255, 0.85); --display-badge-bg: rgba(255, 255, 255, 0.85);
--primary-text-color: #3d8b40; --primary-text-color: #3d8b40;
@@ -81,6 +117,7 @@ html {
background: var(--bg-color); background: var(--bg-color);
overflow-y: scroll; overflow-y: scroll;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-gutter: stable;
} }
body { body {
@@ -90,9 +127,8 @@ body {
line-height: 1.6; line-height: 1.6;
} }
body.modal-open { html.modal-open {
position: fixed; overflow: hidden; /* scrollbar-gutter: stable keeps the gutter reserved */
width: 100%;
} }
/* ── Ambient animated background ── */ /* ── Ambient animated background ── */
@@ -186,7 +222,7 @@ body,
.dashboard-target, .dashboard-target,
.perf-chart-card, .perf-chart-card,
header { header {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease, border-color var(--duration-normal) ease;
} }
/* ── Respect reduced motion preference ── */ /* ── Respect reduced motion preference ── */

View File

@@ -2,6 +2,59 @@ section {
margin-bottom: 40px; margin-bottom: 40px;
} }
/* ── Skeleton loading placeholders ── */
@keyframes skeletonPulse {
0%, 100% { opacity: 0.06; }
50% { opacity: 0.12; }
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 140px;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-line-title {
width: 60%;
height: 18px;
}
.skeleton-line-short {
width: 40%;
}
.skeleton-line-medium {
width: 75%;
}
.skeleton-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.skeleton-btn {
height: 32px;
flex: 1;
border-radius: var(--radius-sm);
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.displays-grid, .displays-grid,
.devices-grid { .devices-grid {
display: grid; display: grid;
@@ -54,7 +107,7 @@ section {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 12px 20px 20px; padding: 16px 20px 20px;
position: relative; position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex; display: flex;
@@ -152,6 +205,17 @@ section {
animation: rotateBorder 4s linear infinite; animation: rotateBorder 4s linear infinite;
} }
/* Fallback for browsers without mask-composite support (older Firefox) */
@supports not (mask-composite: exclude) {
.card-running::before {
-webkit-mask: none;
mask: none;
background: none;
border: 2px solid var(--primary-color);
opacity: 0.7;
}
}
@keyframes rotateBorder { @keyframes rotateBorder {
to { --border-angle: 360deg; } to { --border-angle: 360deg; }
} }
@@ -267,8 +331,9 @@ body.cs-drag-active .card-drag-handle {
opacity: 0 !important; opacity: 0 !important;
} }
/* Hide drag handles when filter is active */ /* Hide drag handles when filter is active or bulk selecting */
.cs-filtering .card-drag-handle { .cs-filtering .card-drag-handle,
.cs-selecting .card-drag-handle {
display: none; display: none;
} }
@@ -1112,3 +1177,146 @@ ul.section-tip li {
.led-preview-layers:hover .led-preview-layer-label { .led-preview-layers:hover .led-preview-layer-label {
opacity: 1; opacity: 1;
} }
/* ── Bulk selection ────────────────────────────────────────── */
/* Toggle button in section header */
.cs-bulk-toggle {
background: none;
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 0.75rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: color 0.2s, background 0.2s, border-color 0.2s;
flex-shrink: 0;
line-height: 1;
}
.cs-bulk-toggle:hover {
border-color: var(--primary-color);
color: var(--primary-text-color);
}
.cs-bulk-toggle.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast, #fff);
}
/* Checkbox inside card — hidden unless selecting */
.card-bulk-check {
display: none;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
flex-shrink: 0;
}
.cs-selecting .card-bulk-check {
display: block;
}
/* Selected card highlight */
.cs-selecting .card-selected,
.cs-selecting .card-selected.template-card {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent);
}
/* Make cards visually clickable in selection mode */
.cs-selecting .card,
.cs-selecting .template-card {
cursor: pointer;
}
/* Suppress hover lift during selection */
.cs-selecting .card:hover,
.cs-selecting .template-card:hover {
transform: none;
}
/* ── Bulk toolbar ──────────────────────────────────────────── */
#bulk-toolbar {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%) translateY(calc(100% + 30px));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
z-index: var(--z-bulk-toolbar);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
transition: transform 0.25s ease;
white-space: nowrap;
}
#bulk-toolbar.visible {
transform: translateX(-50%) translateY(0);
}
.bulk-select-all-wrap {
display: flex;
align-items: center;
cursor: pointer;
}
.bulk-select-all-cb {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary-color);
cursor: pointer;
}
.bulk-count {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 80px;
}
.bulk-actions {
display: flex;
gap: 4px;
}
.bulk-action-btn {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.bulk-action-btn .icon {
width: 16px;
height: 16px;
}
.bulk-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
line-height: 1;
}
.bulk-close:hover {
color: var(--text-color);
}

View File

@@ -193,6 +193,21 @@ select:focus {
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15); box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
} }
/* Inline validation states */
input.field-invalid,
select.field-invalid {
border-color: var(--danger-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger-color) 15%, transparent);
}
.field-error-msg {
display: block;
color: var(--danger-color);
font-size: 0.78rem;
margin-top: 4px;
line-height: 1.3;
}
/* Remove browser autofill styling */ /* Remove browser autofill styling */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
@@ -260,7 +275,7 @@ input:-webkit-autofill:focus {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; z-index: var(--z-overlay-spinner);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -353,7 +368,7 @@ input:-webkit-autofill:focus {
font-size: 15px; font-size: 15px;
opacity: 0; opacity: 0;
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 3000; z-index: var(--z-toast);
box-shadow: 0 4px 20px var(--shadow-color); box-shadow: 0 4px 20px var(--shadow-color);
min-width: 300px; min-width: 300px;
text-align: center; text-align: center;
@@ -384,6 +399,52 @@ input:-webkit-autofill:focus {
background: var(--info-color); background: var(--info-color);
} }
/* Toast with undo action */
.toast-with-action {
display: flex;
align-items: center;
gap: 12px;
}
.toast-message {
flex: 1;
}
.toast-undo-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-weight: var(--weight-semibold, 600);
font-size: 0.85rem;
cursor: pointer;
transition: background var(--duration-fast, 0.15s);
white-space: nowrap;
flex-shrink: 0;
}
.toast-undo-btn:hover {
background: rgba(255, 255, 255, 0.4);
}
.toast-timer {
width: 100%;
height: 3px;
position: absolute;
bottom: 0;
left: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: rgba(255, 255, 255, 0.3);
transform-origin: left;
animation: toastTimer var(--toast-duration, 5s) linear forwards;
}
@keyframes toastTimer {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* ── Card Tags ──────────────────────────────────────────── */ /* ── Card Tags ──────────────────────────────────────────── */
.card-tags { .card-tags {
@@ -604,7 +665,7 @@ textarea:focus-visible {
.icon-select-popup { .icon-select-popup {
position: fixed; position: fixed;
z-index: 10000; z-index: var(--z-lightbox);
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
@@ -683,7 +744,7 @@ textarea:focus-visible {
.type-picker-overlay { .type-picker-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 3000; z-index: var(--z-command-palette);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 15vh; padding-top: 15vh;
@@ -758,7 +819,7 @@ textarea:focus-visible {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; z-index: var(--z-lightbox);
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;

View File

@@ -516,8 +516,10 @@ html:has(#tab-graph.active) {
stroke-width: 2.5; stroke-width: 2.5;
} }
.graph-edge.dimmed { .graph-edge.dimmed,
.graph-edge.dimmed.graph-edge-active {
opacity: 0.12; opacity: 0.12;
filter: none;
} }
/* Nested edges (composite layers, zones) — not drag-editable */ /* Nested edges (composite layers, zones) — not drag-editable */

View File

@@ -5,7 +5,7 @@ header {
padding: 8px 20px; padding: 8px 20px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: var(--z-sticky);
background: var(--bg-color); background: var(--bg-color);
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
} }
@@ -133,7 +133,7 @@ h2 {
.connection-overlay { .connection-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; z-index: var(--z-connection);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -177,6 +177,19 @@ h2 {
animation: conn-spin 0.8s linear infinite; animation: conn-spin 0.8s linear infinite;
} }
/* Visually hidden — screen readers only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* WLED device health indicator */ /* WLED device health indicator */
.health-dot { .health-dot {
display: inline-block; display: inline-block;
@@ -448,7 +461,7 @@ h2 {
#command-palette { #command-palette {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 3000; z-index: var(--z-command-palette);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 15vh; padding-top: 15vh;

View File

@@ -154,7 +154,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: var(--z-sticky);
background: var(--card-bg); background: var(--card-bg);
border-bottom: none; border-bottom: none;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);

View File

@@ -7,7 +7,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
z-index: 2000; z-index: var(--z-modal);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
@@ -16,7 +16,7 @@
/* Confirm dialog must stack above all other modals */ /* Confirm dialog must stack above all other modals */
#confirm-modal { #confirm-modal {
z-index: 2500; z-index: var(--z-confirm);
} }
/* Audio test spectrum canvas */ /* Audio test spectrum canvas */
@@ -393,7 +393,7 @@
.log-overlay { .log-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2100; z-index: var(--z-log-overlay);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-color, #111); background: var(--bg-color, #111);
@@ -1007,7 +1007,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.92); background: rgba(0, 0, 0, 0.92);
z-index: 10000; z-index: var(--z-lightbox);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: zoom-out; cursor: zoom-out;

View File

@@ -36,6 +36,7 @@
.stream-card-props { .stream-card-props {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start;
gap: 6px; gap: 6px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -59,7 +60,7 @@
} }
.stream-card-prop-full { .stream-card-prop-full {
flex: 1 1 100%; max-width: 100%;
min-width: 0; min-width: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@@ -1,22 +1,20 @@
/* =========================== /* ===========================
Tree Sidebar Navigation Tree Dropdown Navigation
=========================== */ =========================== */
.tree-layout { .tree-layout {
display: flex; display: flex;
gap: 20px; flex-direction: column;
align-items: flex-start; gap: 0;
} }
.tree-sidebar { .tree-sidebar {
width: 210px;
min-width: 210px;
flex-shrink: 0;
position: sticky; position: sticky;
top: calc(var(--sticky-top, 90px) + 8px); top: var(--sticky-top, 90px);
max-height: calc(100vh - var(--sticky-top, 90px) - 24px); z-index: 20;
overflow-y: auto; padding: 8px 0 4px;
padding: 4px 0; /* dropdown panel positions against this */
/* sticky already establishes containing block, but be explicit */
} }
.tree-content { .tree-content {
@@ -24,62 +22,144 @@
min-width: 0; min-width: 0;
} }
/* ── Group ── */ /* ── Trigger bar ── */
.tree-group { .tree-dd-trigger {
margin-bottom: 2px; display: inline-flex;
}
.tree-group:first-child > .tree-group-header {
margin-top: 0;
}
.tree-group-header {
display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 10px; padding: 5px 10px;
cursor: pointer; cursor: pointer;
user-select: none; border: 1px solid var(--border-color);
font-size: 0.72rem; border-radius: 8px;
font-weight: 700;
color: var(--text-muted);
border-radius: 6px;
transition: color 0.15s, background 0.15s;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 6px;
}
.tree-group-header:hover {
color: var(--text-color);
background: var(--bg-secondary); background: var(--bg-secondary);
user-select: none;
font-size: 0.82rem;
color: var(--text-color);
transition: border-color 0.15s, background 0.15s;
} }
.tree-chevron { .tree-dd-trigger:hover {
font-size: 0.5rem; border-color: var(--primary-color);
width: 10px; background: color-mix(in srgb, var(--primary-color) 6%, var(--bg-secondary));
display: inline-block;
flex-shrink: 0;
transition: transform 0.2s ease;
color: var(--text-secondary);
} }
.tree-chevron.open { .tree-dd-trigger.open {
transform: rotate(90deg); border-color: var(--primary-color);
} }
.tree-node-icon { .tree-dd-trigger-icon {
flex-shrink: 0; flex-shrink: 0;
line-height: 1; line-height: 1;
} }
.tree-node-icon .icon { .tree-dd-trigger-icon .icon {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
.tree-node-title { .tree-dd-trigger-title {
font-weight: 600;
white-space: nowrap;
}
.tree-dd-trigger-count {
background: var(--primary-color);
color: var(--primary-contrast);
font-size: 0.6rem;
font-weight: 600;
padding: 0 5px;
border-radius: 8px;
min-width: 16px;
text-align: center;
}
.tree-dd-chevron {
font-size: 0.65rem;
color: var(--text-muted);
transition: transform 0.2s ease;
margin-left: 2px;
}
.tree-dd-trigger.open .tree-dd-chevron {
transform: rotate(180deg);
}
.tree-dd-extra {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
border-left: 1px solid var(--border-color);
padding-left: 8px;
}
/* ── Dropdown panel ── */
.tree-dd-panel {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 240px;
max-width: 340px;
max-height: 70vh;
overflow-y: auto;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 100;
padding: 4px 0;
margin-top: 4px;
}
.tree-dd-panel.open {
display: block;
}
/* ── Group header (non-clickable category label) ── */
.tree-dd-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px 3px;
font-size: 0.68rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
}
.tree-dd-group-header.tree-dd-depth-1 {
padding-left: 24px;
font-size: 0.72rem;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.tree-dd-group-header.tree-dd-depth-2 {
padding-left: 36px;
font-size: 0.72rem;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.tree-dd-group-header .tree-node-icon {
flex-shrink: 0;
line-height: 1;
}
.tree-dd-group-header .tree-node-icon .icon {
width: 13px;
height: 13px;
}
.tree-dd-group-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -87,108 +167,76 @@
white-space: nowrap; white-space: nowrap;
} }
.tree-group-count { .tree-dd-group-count {
background: var(--border-color); background: var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.6rem; font-size: 0.58rem;
font-weight: 600; font-weight: 600;
padding: 0 5px; padding: 0 4px;
border-radius: 8px; border-radius: 8px;
flex-shrink: 0; min-width: 14px;
min-width: 16px;
text-align: center; text-align: center;
flex-shrink: 0;
} }
/* ── Nested sub-group (group inside a group) ── */ /* ── Leaf (clickable item) ── */
.tree-group-nested > .tree-group-header { .tree-dd-leaf {
font-size: 0.75rem; display: flex;
text-transform: none; align-items: center;
letter-spacing: normal; gap: 6px;
padding: 5px 12px 5px 20px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
transition: color 0.1s, background 0.1s;
}
/* Indent leaves inside nested groups */
.tree-dd-group-header.tree-dd-depth-1 ~ .tree-dd-children > .tree-dd-leaf,
.tree-dd-group .tree-dd-group .tree-dd-leaf {
padding-left: 32px;
}
.tree-dd-group .tree-dd-group .tree-dd-group .tree-dd-leaf {
padding-left: 44px;
}
.tree-dd-leaf:hover {
color: var(--text-color);
background: var(--bg-secondary);
}
.tree-dd-leaf.active {
color: var(--primary-text-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
font-weight: 600; font-weight: 600;
margin-top: 2px;
padding: 4px 10px 4px 12px;
} }
/* ── Children (leaves) ── */ .tree-dd-leaf.active .tree-count {
background: var(--primary-color);
color: var(--primary-contrast);
}
.tree-children { .tree-dd-leaf .tree-node-icon {
flex-shrink: 0;
line-height: 1;
}
.tree-dd-leaf .tree-node-icon .icon {
width: 14px;
height: 14px;
}
.tree-dd-leaf .tree-node-title {
flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
margin-left: 14px; text-overflow: ellipsis;
border-left: 1px solid var(--border-color); white-space: nowrap;
padding-left: 0;
max-height: 500px;
opacity: 1;
transition: max-height 0.25s ease, opacity 0.2s ease;
} }
.tree-children.collapsed { /* ── Count badge (shared) ── */
max-height: 0;
opacity: 0;
}
.tree-leaf {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 12px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
border-radius: 0 6px 6px 0;
margin: 1px 0;
transition: color 0.15s, background 0.15s;
}
.tree-leaf:hover {
color: var(--text-color);
background: var(--bg-secondary);
}
.tree-leaf.active {
color: var(--primary-text-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
font-weight: 600;
}
.tree-leaf.active .tree-count {
background: var(--primary-color);
color: var(--primary-contrast);
}
/* ── Standalone leaf (top-level, no group) ── */
.tree-standalone {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
border-radius: 6px;
margin: 1px 0;
transition: color 0.15s, background 0.15s;
}
.tree-standalone:hover {
color: var(--text-color);
background: var(--bg-secondary);
}
.tree-standalone.active {
color: var(--primary-text-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
font-weight: 600;
}
.tree-standalone.active .tree-count {
background: var(--primary-color);
color: var(--primary-contrast);
}
/* ── Count badge ── */
.tree-count { .tree-count {
background: var(--border-color); background: var(--border-color);
@@ -202,96 +250,10 @@
text-align: center; text-align: center;
} }
/* ── Extra (expand/collapse, tutorial buttons) ── */ /* ── Group separator ── */
.tree-extra { .tree-dd-group + .tree-dd-group {
padding: 8px 10px;
margin-top: 4px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
display: flex; margin-top: 2px;
gap: 4px; padding-top: 2px;
align-items: center;
}
/* ── Sidebar eats into card space — allow 2-col with smaller minmax ── */
.tree-content .displays-grid,
.tree-content .devices-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.tree-content .templates-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* ── Responsive: stack on narrow screens ── */
@media (max-width: 900px) {
.tree-layout {
flex-direction: column;
align-items: stretch;
gap: 0;
}
.tree-sidebar {
width: 100%;
min-width: unset;
position: static;
max-height: none;
overflow-y: visible;
padding: 0 0 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
}
.tree-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 0;
}
.tree-group-header {
padding: 4px 8px;
text-transform: none;
letter-spacing: normal;
}
.tree-children {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-left: 0;
border-left: none;
padding-left: 0;
max-height: none;
opacity: 1;
transition: none;
}
.tree-children.collapsed {
display: none;
}
.tree-leaf {
padding: 4px 10px;
margin: 0;
}
.tree-standalone {
padding: 4px 10px;
}
.tree-extra {
margin-top: 0;
border-top: none;
padding: 4px;
margin-left: auto;
}
} }

View File

@@ -3,34 +3,35 @@
*/ */
// Layer 0: state // Layer 0: state
import { apiKey, setApiKey, refreshInterval } from './core/state.js'; import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
import { Modal } from './core/modal.js'; import { Modal } from './core/modal.ts';
// Layer 1: api, i18n // Layer 1: api, i18n
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.js'; import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
import { t, initLocale, changeLocale } from './core/i18n.js'; import { t, initLocale, changeLocale } from './core/i18n.ts';
// Layer 1.5: visual effects // Layer 1.5: visual effects
import { initCardGlare } from './core/card-glare.js'; import { initCardGlare } from './core/card-glare.ts';
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.js'; import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.js'; import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
// Layer 2: ui // Layer 2: ui
import { import {
toggleHint, lockBody, unlockBody, closeLightbox, toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showConfirm, closeConfirmModal, showToast, showUndoToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
} from './core/ui.js'; setFieldError, clearFieldError, setupBlurValidation,
} from './core/ui.ts';
// Layer 3: displays, tutorials // Layer 3: displays, tutorials
import { import {
openDisplayPicker, closeDisplayPicker, selectDisplay, formatDisplayLabel, openDisplayPicker, closeDisplayPicker, selectDisplay, formatDisplayLabel,
} from './features/displays.js'; } from './features/displays.ts';
import { import {
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial, startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial, startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
closeTutorial, tutorialNext, tutorialPrev, closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.js'; } from './features/tutorials.ts';
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations // Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations
import { import {
@@ -38,18 +39,18 @@ import {
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
turnOffDevice, pingDevice, removeDevice, loadDevices, turnOffDevice, pingDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint, copyWsUrl, updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.js'; } from './features/devices.ts';
import { import {
loadDashboard, stopUptimeTimer, loadDashboard, stopUptimeTimer,
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
dashboardPauseClock, dashboardResumeClock, dashboardResetClock, dashboardPauseClock, dashboardResumeClock, dashboardResetClock,
toggleDashboardSection, changeDashboardPollInterval, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.ts';
import { startEventsWS, stopEventsWS } from './core/events-ws.js'; import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
import { startEntityEventListeners } from './core/entity-events.js'; import { startEntityEventListeners } from './core/entity-events.ts';
import { import {
startPerfPolling, stopPerfPolling, startPerfPolling, stopPerfPolling,
} from './features/perf-charts.js'; } from './features/perf-charts.ts';
import { import {
loadPictureSources, switchStreamTab, loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate, showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
@@ -68,15 +69,14 @@ import {
showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT, showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT,
csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption, csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption,
renderCSPTModalFilterList, renderCSPTModalFilterList,
expandAllStreamSections, collapseAllStreamSections, } from './features/streams.ts';
} from './features/streams.js';
import { import {
createKCTargetCard, testKCTarget, createKCTargetCard, testKCTarget,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor, showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets, deleteKCTarget, disconnectAllKCWebSockets,
updateKCBrightnessLabel, saveKCBrightness, updateKCBrightnessLabel, saveKCBrightness,
cloneKCTarget, cloneKCTarget,
} from './features/kc-targets.js'; } from './features/kc-targets.ts';
import { import {
createPatternTemplateCard, createPatternTemplateCard,
showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal, showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal,
@@ -85,25 +85,24 @@ import {
addPatternRect, deleteSelectedPatternRect, removePatternRect, addPatternRect, deleteSelectedPatternRect, removePatternRect,
capturePatternBackground, capturePatternBackground,
clonePatternTemplate, clonePatternTemplate,
} from './features/pattern-templates.js'; } from './features/pattern-templates.ts';
import { import {
loadAutomations, openAutomationEditor, closeAutomationEditorModal, loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition, saveAutomationEditor, addAutomationCondition,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
expandAllAutomationSections, collapseAllAutomationSections, } from './features/automations.ts';
} from './features/automations.js';
import { import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset, activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget, removeSceneTarget, addSceneTarget, removeSceneTarget,
} from './features/scene-presets.js'; } from './features/scene-presets.ts';
// Layer 5: device-discovery, targets // Layer 5: device-discovery, targets
import { import {
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus, onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice, showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
cloneDevice, cloneDevice,
} from './features/device-discovery.js'; } from './features/device-discovery.ts';
import { import {
loadTargetsTab, switchTargetSubTab, loadTargetsTab, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
@@ -111,9 +110,8 @@ import {
stopAllLedTargets, stopAllKCTargets, stopAllLedTargets, stopAllKCTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview, cloneTarget, toggleLedPreview,
expandAllTargetSections, collapseAllTargetSections,
disconnectAllLedPreviewWS, disconnectAllLedPreviewWS,
} from './features/targets.js'; } from './features/targets.ts';
// Layer 5: color-strip sources // Layer 5: color-strip sources
import { import {
@@ -137,7 +135,7 @@ import {
testNotification, testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
} from './features/color-strips.js'; } from './features/color-strips.ts';
// Layer 5: audio sources // Layer 5: audio sources
import { import {
@@ -145,7 +143,7 @@ import {
editAudioSource, cloneAudioSource, deleteAudioSource, editAudioSource, cloneAudioSource, deleteAudioSource,
testAudioSource, closeTestAudioSourceModal, testAudioSource, closeTestAudioSourceModal,
refreshAudioDevices, refreshAudioDevices,
} from './features/audio-sources.js'; } from './features/audio-sources.ts';
// Layer 5: value sources // Layer 5: value sources
import { import {
@@ -154,7 +152,7 @@ import {
onDaylightVSRealTimeChange, onDaylightVSRealTimeChange,
addSchedulePoint, addSchedulePoint,
testValueSource, closeTestValueSourceModal, testValueSource, closeTestValueSourceModal,
} from './features/value-sources.js'; } from './features/value-sources.ts';
// Layer 5: calibration // Layer 5: calibration
import { import {
@@ -162,12 +160,12 @@ import {
updateOffsetSkipLock, updateCalibrationPreview, updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge, setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration, toggleCalibrationOverlay, showCSSCalibration, toggleCalibrationOverlay,
} from './features/calibration.js'; } from './features/calibration.ts';
import { import {
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration, showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine, addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
updateCalibrationLine, resetCalibrationView, updateCalibrationLine, resetCalibrationView,
} from './features/advanced-calibration.js'; } from './features/advanced-calibration.ts';
// Layer 5.5: graph editor // Layer 5.5: graph editor
import { import {
@@ -175,12 +173,12 @@ import {
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo, toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
graphToggleFullscreen, graphAddEntity, graphToggleFullscreen, graphAddEntity,
} from './features/graph-editor.js'; } from './features/graph-editor.ts';
// Layer 6: tabs, navigation, command palette, settings // Layer 6: tabs, navigation, command palette, settings
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js'; import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
import { navigateToCard } from './core/navigation.js'; import { navigateToCard } from './core/navigation.ts';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
import { import {
openSettingsModal, closeSettingsModal, switchSettingsTab, openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected, downloadBackup, handleRestoreFileSelected,
@@ -192,7 +190,7 @@ import {
openLogOverlay, closeLogOverlay, openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
saveExternalUrl, getBaseOrigin, loadExternalUrl, saveExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.js'; } from './features/settings.ts';
// ─── Register all HTML onclick / onchange / onfocus globals ─── // ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -211,11 +209,15 @@ Object.assign(window, {
unlockBody, unlockBody,
closeLightbox, closeLightbox,
showToast, showToast,
showUndoToast,
showConfirm, showConfirm,
closeConfirmModal, closeConfirmModal,
openFullImageLightbox, openFullImageLightbox,
showOverlaySpinner, showOverlaySpinner,
hideOverlaySpinner, hideOverlaySpinner,
setFieldError,
clearFieldError,
setupBlurValidation,
// core / api + i18n // core / api + i18n
t, t,
@@ -275,7 +277,6 @@ Object.assign(window, {
// streams / capture templates / PP templates // streams / capture templates / PP templates
loadPictureSources, loadPictureSources,
switchStreamTab, switchStreamTab,
expandAllStreamSections, collapseAllStreamSections,
showAddTemplateModal, showAddTemplateModal,
editTemplate, editTemplate,
closeTemplateModal, closeTemplateModal,
@@ -369,6 +370,7 @@ Object.assign(window, {
// automations // automations
loadAutomations, loadAutomations,
switchAutomationTab,
openAutomationEditor, openAutomationEditor,
closeAutomationEditorModal, closeAutomationEditorModal,
saveAutomationEditor, saveAutomationEditor,
@@ -377,8 +379,6 @@ Object.assign(window, {
cloneAutomation, cloneAutomation,
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
expandAllAutomationSections,
collapseAllAutomationSections,
// scene presets // scene presets
openScenePresetCapture, openScenePresetCapture,
@@ -404,7 +404,6 @@ Object.assign(window, {
// targets // targets
loadTargetsTab, loadTargetsTab,
switchTargetSubTab, switchTargetSubTab,
expandAllTargetSections, collapseAllTargetSections,
showTargetEditor, showTargetEditor,
closeTargetEditorModal, closeTargetEditorModal,
forceCloseTargetEditorModal, forceCloseTargetEditorModal,
@@ -582,9 +581,9 @@ document.addEventListener('keydown', (e) => {
if (logOverlay && logOverlay.style.display !== 'none') { if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay(); closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) { } else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker(); closeDisplayPicker(null as any);
} else if (document.getElementById('image-lightbox').classList.contains('active')) { } else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox(); closeLightbox(null as any);
} else { } else {
Modal.closeTopmost(); Modal.closeTopmost();
} }

View File

@@ -2,13 +2,14 @@
* API utilities base URL, auth headers, fetch wrapper, helpers. * API utilities base URL, auth headers, fetch wrapper, helpers.
*/ */
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.js'; import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { showToast } from './ui.ts';
export const API_BASE = '/api/v1'; export const API_BASE = '/api/v1';
export function getHeaders() { export function getHeaders() {
const headers = { const headers: Record<string, string> = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
if (apiKey) { if (apiKey) {
@@ -18,7 +19,10 @@ export function getHeaders() {
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(status, message) { status: number;
isAuth: boolean;
constructor(status: number, message: string) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.status = status; this.status = status;
@@ -26,7 +30,13 @@ export class ApiError extends Error {
} }
} }
export async function fetchWithAuth(url, options = {}) { interface FetchAuthOpts extends RequestInit {
retry?: boolean;
timeout?: number;
handle401?: boolean;
}
export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): Promise<Response> {
const { retry = true, timeout = 10000, handle401: auto401 = true, ...fetchOpts } = options; const { retry = true, timeout = 10000, handle401: auto401 = true, ...fetchOpts } = options;
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const headers = fetchOpts.headers const headers = fetchOpts.headers
@@ -59,63 +69,69 @@ export async function fetchWithAuth(url, options = {}) {
await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue; continue;
} }
// Final attempt failed — show user-facing error
const errMsg = (err as Error)?.name === 'AbortError'
? t('api.error.timeout')
: t('api.error.network');
showToast(errMsg, 'error');
throw err; throw err;
} }
} }
return undefined as unknown as Response;
} }
export function escapeHtml(text) { export function escapeHtml(text: string) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
export function isSerialDevice(type) { export function isSerialDevice(type: string) {
return type === 'adalight' || type === 'ambiled'; return type === 'adalight' || type === 'ambiled';
} }
export function isMockDevice(type) { export function isMockDevice(type: string) {
return type === 'mock'; return type === 'mock';
} }
export function isMqttDevice(type) { export function isMqttDevice(type: string) {
return type === 'mqtt'; return type === 'mqtt';
} }
export function isWsDevice(type) { export function isWsDevice(type: string) {
return type === 'ws'; return type === 'ws';
} }
export function isOpenrgbDevice(type) { export function isOpenrgbDevice(type: string) {
return type === 'openrgb'; return type === 'openrgb';
} }
export function isDmxDevice(type) { export function isDmxDevice(type: string) {
return type === 'dmx'; return type === 'dmx';
} }
export function isEspnowDevice(type) { export function isEspnowDevice(type: string) {
return type === 'espnow'; return type === 'espnow';
} }
export function isHueDevice(type) { export function isHueDevice(type: string) {
return type === 'hue'; return type === 'hue';
} }
export function isUsbhidDevice(type) { export function isUsbhidDevice(type: string) {
return type === 'usbhid'; return type === 'usbhid';
} }
export function isSpiDevice(type) { export function isSpiDevice(type: string) {
return type === 'spi'; return type === 'spi';
} }
export function isChromaDevice(type) { export function isChromaDevice(type: string) {
return type === 'chroma'; return type === 'chroma';
} }
export function isGameSenseDevice(type) { export function isGameSenseDevice(type: string) {
return type === 'gamesense'; return type === 'gamesense';
} }
@@ -147,19 +163,19 @@ export function handle401Error() {
} }
} }
let _connCheckTimer = null; let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
let _serverOnline = null; // null = unknown, true/false let _serverOnline: boolean | null = null; // null = unknown, true/false
function _setConnectionState(online) { function _setConnectionState(online: boolean) {
const changed = _serverOnline !== online; const changed = _serverOnline !== online;
_serverOnline = online; _serverOnline = online;
const banner = document.getElementById('connection-overlay'); const banner = document.getElementById('connection-overlay');
const badge = document.getElementById('server-status'); const badge = document.getElementById('server-status');
if (online) { if (online) {
if (banner) { banner.style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); } if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
if (badge) badge.className = 'status-badge online'; if (badge) badge.className = 'status-badge online';
} else { } else {
if (banner) { banner.style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); } if (banner) { (banner as HTMLElement).style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); }
if (badge) badge.className = 'status-badge offline'; if (badge) badge.className = 'status-badge offline';
} }
return changed; return changed;
@@ -170,8 +186,8 @@ export async function loadServerInfo() {
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) }); const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
const data = await response.json(); const data = await response.json();
document.getElementById('version-number').textContent = `v${data.version}`; document.getElementById('version-number')!.textContent = `v${data.version}`;
document.getElementById('server-status').textContent = '●'; document.getElementById('server-status')!.textContent = '●';
const wasOffline = _serverOnline === false; const wasOffline = _serverOnline === false;
_setConnectionState(true); _setConnectionState(true);
if (wasOffline) { if (wasOffline) {
@@ -200,7 +216,7 @@ export function stopConnectionMonitor() {
} }
} }
export async function loadDisplays(engineType = null) { export async function loadDisplays(engineType: string | null = null) {
if (engineType) { if (engineType) {
// Filtered fetch — bypass cache (engine-specific display list) // Filtered fetch — bypass cache (engine-specific display list)
try { try {
@@ -233,11 +249,11 @@ export function configureApiKey() {
if (key === '') { if (key === '') {
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');
setApiKey(null); setApiKey(null);
document.getElementById('api-key-btn').style.display = 'none'; document.getElementById('api-key-btn')!.style.display = 'none';
} else { } else {
localStorage.setItem('wled_api_key', key); localStorage.setItem('wled_api_key', key);
setApiKey(key); setApiKey(key);
document.getElementById('api-key-btn').style.display = 'inline-block'; document.getElementById('api-key-btn')!.style.display = 'inline-block';
} }
loadServerInfo(); loadServerInfo();

View File

@@ -108,8 +108,8 @@ void main() {
let _canvas, _gl, _prog; let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase; let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
let _particleBuf = null; // pre-allocated Float32Array for uniform3fv let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
let _raf = null; let _raf: number | null = null;
let _startTime = 0; let _startTime = 0;
let _accent = [76 / 255, 175 / 255, 80 / 255]; let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26 / 255, 26 / 255, 26 / 255]; let _bgColor = [26 / 255, 26 / 255, 26 / 255];
@@ -118,7 +118,7 @@ let _isLight = 0.0;
// Particle state (CPU-side, positions in 0..1 UV space) // Particle state (CPU-side, positions in 0..1 UV space)
const _particles = []; const _particles = [];
function _initParticles() { function _initParticles(): void {
_particles.length = 0; _particles.length = 0;
for (let i = 0; i < PARTICLE_COUNT; i++) { for (let i = 0; i < PARTICLE_COUNT; i++) {
_particles.push({ _particles.push({
@@ -131,7 +131,7 @@ function _initParticles() {
} }
} }
function _updateParticles() { function _updateParticles(): void {
for (const p of _particles) { for (const p of _particles) {
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
@@ -142,7 +142,7 @@ function _updateParticles() {
} }
} }
function _compile(gl, type, src) { function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
const s = gl.createShader(type); const s = gl.createShader(type);
gl.shaderSource(s, src); gl.shaderSource(s, src);
gl.compileShader(s); gl.compileShader(s);
@@ -153,7 +153,7 @@ function _compile(gl, type, src) {
return s; return s;
} }
function _initGL() { function _initGL(): boolean {
_gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false }); _gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false });
if (!_gl) return false; if (!_gl) return false;
const gl = _gl; const gl = _gl;
@@ -191,7 +191,7 @@ function _initGL() {
return true; return true;
} }
function _resize() { function _resize(): void {
const w = Math.round(window.innerWidth * 0.5); const w = Math.round(window.innerWidth * 0.5);
const h = Math.round(window.innerHeight * 0.5); const h = Math.round(window.innerHeight * 0.5);
_canvas.width = w; _canvas.width = w;
@@ -199,7 +199,7 @@ function _resize() {
if (_gl) _gl.viewport(0, 0, w, h); if (_gl) _gl.viewport(0, 0, w, h);
} }
function _draw(time) { function _draw(time: number): void {
_raf = requestAnimationFrame(_draw); _raf = requestAnimationFrame(_draw);
const gl = _gl; const gl = _gl;
if (!gl) return; if (!gl) return;
@@ -225,7 +225,7 @@ function _draw(time) {
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
} }
function _start() { function _start(): void {
if (_raf) return; if (_raf) return;
if (!_gl && !_initGL()) return; if (!_gl && !_initGL()) return;
_resize(); _resize();
@@ -234,14 +234,14 @@ function _start() {
_raf = requestAnimationFrame(_draw); _raf = requestAnimationFrame(_draw);
} }
function _stop() { function _stop(): void {
if (_raf) { if (_raf) {
cancelAnimationFrame(_raf); cancelAnimationFrame(_raf);
_raf = null; _raf = null;
} }
} }
function hexToNorm(hex) { function hexToNorm(hex: string): number[] {
return [ return [
parseInt(hex.slice(1, 3), 16) / 255, parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255, parseInt(hex.slice(3, 5), 16) / 255,
@@ -249,16 +249,16 @@ function hexToNorm(hex) {
]; ];
} }
export function updateBgAnimAccent(hex) { export function updateBgAnimAccent(hex: string): void {
_accent = hexToNorm(hex); _accent = hexToNorm(hex);
} }
export function updateBgAnimTheme(isDark) { export function updateBgAnimTheme(isDark: boolean): void {
_bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255];
_isLight = isDark ? 0.0 : 1.0; _isLight = isDark ? 0.0 : 1.0;
} }
export function initBgAnim() { export function initBgAnim(): void {
_canvas = document.getElementById('bg-anim-canvas'); _canvas = document.getElementById('bg-anim-canvas');
if (!_canvas) return; if (!_canvas) return;

View File

@@ -0,0 +1,128 @@
/**
* BulkToolbar — fixed-bottom action bar for card bulk operations.
*
* Singleton toolbar: only one section can be in bulk mode at a time.
* Renders dynamically based on the active section's configured actions.
*
* Each bulk action descriptor:
* { key, labelKey, icon?, style?, confirm?, handler }
* - icon: SVG HTML string (from icons.js) — rendered inside the button
*/
import { t } from './i18n.ts';
import { showConfirm } from './ui.ts';
import type { CardSection } from './card-sections.ts';
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
let _toolbarEl: HTMLDivElement | null = null; // cached DOM element
function _ensureEl() {
if (_toolbarEl) return _toolbarEl;
_toolbarEl = document.createElement('div');
_toolbarEl.id = 'bulk-toolbar';
_toolbarEl.setAttribute('role', 'toolbar');
_toolbarEl.setAttribute('aria-label', 'Bulk actions');
document.body.appendChild(_toolbarEl);
return _toolbarEl;
}
/** Show the toolbar for a given section. */
export function showBulkToolbar(section) {
if (_activeSection && _activeSection !== section) {
_activeSection.exitSelectionMode();
}
_activeSection = section;
_render();
}
/** Hide the toolbar. */
export function hideBulkToolbar() {
_activeSection = null;
if (_toolbarEl) _toolbarEl.classList.remove('visible');
}
/** Re-render toolbar (e.g. after selection count changes). */
export function updateBulkToolbar() {
if (!_activeSection) return;
_render();
}
function _render() {
const el = _ensureEl();
const section = _activeSection;
if (!section) { el.classList.remove('visible'); return; }
const count = section._selected.size;
const actions = section.bulkActions || [];
const actionBtns = actions.map(a => {
const cls = a.style === 'danger' ? 'btn btn-icon btn-danger bulk-action-btn' : 'btn btn-icon btn-secondary bulk-action-btn';
const label = t(a.labelKey);
const inner = a.icon || label;
return `<button class="${cls}" data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
}).join('');
el.innerHTML = `
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
</label>
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
<div class="bulk-actions">${actionBtns}</div>
<button class="bulk-close" title="${t('bulk.cancel')}">&#x2715;</button>
`;
// Select All checkbox
el.querySelector('.bulk-select-all-cb').addEventListener('change', (e) => {
if ((e.target as HTMLInputElement).checked) section.selectAll();
else section.deselectAll();
});
// Action buttons
el.querySelectorAll('[data-bulk-action]').forEach(btn => {
btn.addEventListener('click', () => _executeAction((btn as HTMLElement).dataset.bulkAction));
});
// Close button
el.querySelector('.bulk-close').addEventListener('click', () => {
section.exitSelectionMode();
});
el.classList.add('visible');
}
async function _executeAction(actionKey) {
const section = _activeSection;
if (!section) return;
const action = section.bulkActions.find(a => a.key === actionKey);
if (!action) return;
const keys = [...section._selected];
if (!keys.length) return;
if (action.confirm) {
const msg = t(action.confirm, { count: keys.length });
const ok = await showConfirm(msg);
if (!ok) return;
}
// Show progress state on toolbar
const el = _toolbarEl;
const actionBtns = el?.querySelectorAll('.bulk-action-btn') as NodeListOf<HTMLButtonElement>;
actionBtns?.forEach(btn => { btn.disabled = true; });
const countEl = el?.querySelector('.bulk-count');
const prevCount = countEl?.textContent || '';
if (countEl) countEl.textContent = t('bulk.processing') || 'Processing…';
try {
await action.handler(keys);
} catch (e) {
console.error(`Bulk action "${actionKey}" failed:`, e);
}
// Restore toolbar state (in case exit doesn't happen)
actionBtns?.forEach(btn => { btn.disabled = false; });
if (countEl) countEl.textContent = prevCount;
section.exitSelectionMode();
}

View File

@@ -2,16 +2,33 @@
* Reusable data cache with fetch deduplication, invalidation, and subscribers. * Reusable data cache with fetch deduplication, invalidation, and subscribers.
*/ */
import { fetchWithAuth } from './api.js'; import { fetchWithAuth } from './api.ts';
export type ExtractDataFn<T> = (json: any) => T;
export type SubscriberFn<T> = (data: T) => void;
export interface DataCacheOpts<T> {
endpoint: string;
extractData: ExtractDataFn<T>;
defaultValue?: T;
}
export class DataCache<T = any> {
private _endpoint: string;
private _extractData: ExtractDataFn<T>;
private _defaultValue: T;
private _data: T;
private _loading: boolean;
private _promise: Promise<T> | null;
private _subscribers: SubscriberFn<T>[];
private _fresh: boolean;
export class DataCache {
/** /**
* @param {Object} opts * @param opts.endpoint - API path (e.g. '/picture-sources')
* @param {string} opts.endpoint - API path (e.g. '/picture-sources') * @param opts.extractData - Extract array from response JSON
* @param {function} opts.extractData - Extract array from response JSON * @param opts.defaultValue - Initial/fallback value (default: [])
* @param {*} [opts.defaultValue=[]] - Initial/fallback value
*/ */
constructor({ endpoint, extractData, defaultValue = [] }) { constructor({ endpoint, extractData, defaultValue = [] as unknown as T }: DataCacheOpts<T>) {
this._endpoint = endpoint; this._endpoint = endpoint;
this._extractData = extractData; this._extractData = extractData;
this._defaultValue = defaultValue; this._defaultValue = defaultValue;
@@ -22,16 +39,14 @@ export class DataCache {
this._fresh = false; // true after first successful fetch, cleared on invalidate this._fresh = false; // true after first successful fetch, cleared on invalidate
} }
get data() { return this._data; } get data(): T { return this._data; }
get loading() { return this._loading; } get loading(): boolean { return this._loading; }
/** /**
* Fetch from API. Deduplicates concurrent calls. * Fetch from API. Deduplicates concurrent calls.
* Returns cached data immediately if already fetched and not invalidated. * Returns cached data immediately if already fetched and not invalidated.
* @param {Object} [opts]
* @param {boolean} [opts.force=false] - Force re-fetch even if cache is fresh
*/ */
async fetch({ force = false } = {}) { async fetch({ force = false } = {}): Promise<T> {
if (!force && this._fresh) return this._data; if (!force && this._fresh) return this._data;
if (this._promise) return this._promise; if (this._promise) return this._promise;
this._loading = true; this._loading = true;
@@ -44,7 +59,7 @@ export class DataCache {
} }
} }
async _doFetch() { async _doFetch(): Promise<T> {
try { try {
const resp = await fetchWithAuth(this._endpoint); const resp = await fetchWithAuth(this._endpoint);
if (!resp.ok) { if (!resp.ok) {
@@ -56,7 +71,7 @@ export class DataCache {
this._fresh = true; this._fresh = true;
this._notify(); this._notify();
return this._data; return this._data;
} catch (err) { } catch (err: any) {
if (err.isAuth) return this._data; if (err.isAuth) return this._data;
console.error(`Cache fetch ${this._endpoint}:`, err); console.error(`Cache fetch ${this._endpoint}:`, err);
return this._data; return this._data;
@@ -64,21 +79,21 @@ export class DataCache {
} }
/** Mark cache as stale; next fetch() will re-request. */ /** Mark cache as stale; next fetch() will re-request. */
invalidate() { invalidate(): void {
this._fresh = false; this._fresh = false;
} }
/** Manually set cache value (e.g. after a create/update call). */ /** Manually set cache value (e.g. after a create/update call). */
update(value) { update(value: T): void {
this._data = value; this._data = value;
this._fresh = true; this._fresh = true;
this._notify(); this._notify();
} }
subscribe(fn) { this._subscribers.push(fn); } subscribe(fn: SubscriberFn<T>): void { this._subscribers.push(fn); }
unsubscribe(fn) { this._subscribers = this._subscribers.filter(f => f !== fn); } unsubscribe(fn: SubscriberFn<T>): void { this._subscribers = this._subscribers.filter(f => f !== fn); }
_notify() { _notify(): void {
for (const fn of this._subscribers) fn(this._data); for (const fn of this._subscribers) fn(this._data);
} }
} }

View File

@@ -2,7 +2,7 @@
* Card color assignment localStorage-backed color labels for any card. * Card color assignment localStorage-backed color labels for any card.
* *
* Usage in card creation functions: * Usage in card creation functions:
* import { wrapCard } from '../core/card-colors.js'; * import { wrapCard } from '../core/card-colors.ts';
* *
* return wrapCard({ * return wrapCard({
* dataAttr: 'data-device-id', * dataAttr: 'data-device-id',
@@ -19,21 +19,21 @@
* - Bottom actions (.card-actions / .template-card-actions) with color picker * - Bottom actions (.card-actions / .template-card-actions) with color picker
*/ */
import { createColorPicker, registerColorPicker } from './color-picker.js'; import { createColorPicker, registerColorPicker } from './color-picker.ts';
const STORAGE_KEY = 'cardColors'; const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080'; const DEFAULT_SWATCH = '#808080';
function _getAll() { function _getAll(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
catch { return {}; } catch { return {}; }
} }
export function getCardColor(id) { export function getCardColor(id: string): string {
return _getAll()[id] || ''; return _getAll()[id] || '';
} }
export function setCardColor(id, hex) { export function setCardColor(id: string, hex: string): void {
const m = _getAll(); const m = _getAll();
if (hex) m[id] = hex; else delete m[id]; if (hex) m[id] = hex; else delete m[id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(m)); localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
@@ -43,7 +43,7 @@ export function setCardColor(id, hex) {
* Returns inline style string for card border-left. * Returns inline style string for card border-left.
* Empty string when no color is set. * Empty string when no color is set.
*/ */
export function cardColorStyle(entityId) { export function cardColorStyle(entityId: string): string {
const c = getCardColor(entityId); const c = getCardColor(entityId);
return c ? `border-left: 3px solid ${c}` : ''; return c ? `border-left: 3px solid ${c}` : '';
} }
@@ -53,7 +53,7 @@ export function cardColorStyle(entityId) {
* @param {string} entityId Unique entity ID * @param {string} entityId Unique entity ID
* @param {string} cardAttr Data attribute selector, e.g. 'data-device-id' * @param {string} cardAttr Data attribute selector, e.g. 'data-device-id'
*/ */
export function cardColorButton(entityId, cardAttr) { export function cardColorButton(entityId: string, cardAttr: string): string {
const color = getCardColor(entityId) || DEFAULT_SWATCH; const color = getCardColor(entityId) || DEFAULT_SWATCH;
const pickerId = `cc-${entityId}`; const pickerId = `cc-${entityId}`;
@@ -62,11 +62,11 @@ export function cardColorButton(entityId, cardAttr) {
// Find the card that contains this picker (not a global querySelector // Find the card that contains this picker (not a global querySelector
// which could match a dashboard compact card first) // which could match a dashboard compact card first)
const wrapper = document.getElementById(`cp-wrap-${pickerId}`); const wrapper = document.getElementById(`cp-wrap-${pickerId}`);
const card = wrapper?.closest(`[${cardAttr}]`); const card = wrapper?.closest(`[${cardAttr}]`) as HTMLElement | null;
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : ''; if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
}); });
return createColorPicker({ id: pickerId, currentColor: color, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH }); return createColorPicker({ id: pickerId, currentColor: color, onPick: null, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
} }
/** /**
@@ -98,7 +98,17 @@ export function wrapCard({
removeTitle, removeTitle,
content, content,
actions, actions,
}) { }: {
type?: 'card' | 'template-card';
dataAttr: string;
id: string;
classes?: string;
topButtons?: string;
removeOnclick: string;
removeTitle: string;
content: string;
actions: string;
}): string {
const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions'; const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
const colorStyle = cardColorStyle(id); const colorStyle = cardColorStyle(id);
return ` return `

View File

@@ -8,8 +8,8 @@
const CARD_SEL = '.card, .template-card'; const CARD_SEL = '.card, .template-card';
let _active = null; // currently illuminated card element let _active: Element | null = null; // currently illuminated card element
let _cachedRect = null; // cached bounding rect for current card let _cachedRect: DOMRect | null = null; // cached bounding rect for current card
function _onMove(e) { function _onMove(e) {
const card = e.target.closest(CARD_SEL); const card = e.target.closest(CARD_SEL);

View File

@@ -21,7 +21,34 @@
* } * }
*/ */
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
import { ICON_LIST_CHECKS } from './icons.ts';
export interface BulkAction {
key: string;
labelKey: string;
icon?: string;
style?: string;
confirm?: string;
handler: (ids: string[]) => Promise<void>;
}
export interface CardItem {
key: string;
html: string;
}
export interface CardSectionOpts {
titleKey: string;
gridClass: string;
addCardOnclick?: string;
keyAttr?: string;
headerExtra?: string;
collapsible?: boolean;
emptyKey?: string;
bulkActions?: BulkAction[];
}
const STORAGE_KEY = 'sections_collapsed'; const STORAGE_KEY = 'sections_collapsed';
const ORDER_PREFIX = 'card_order_'; const ORDER_PREFIX = 'card_order_';
@@ -30,24 +57,52 @@ const SCROLL_EDGE = 60;
const SCROLL_SPEED = 12; const SCROLL_SPEED = 12;
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function _getCollapsedMap() { function _getCollapsedMap(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(STORAGE_KEY) as string) || {}; }
catch { return {}; } catch { return {}; }
} }
/** Generate skeleton placeholder cards for loading state. */
export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') {
let html = '';
for (let i = 0; i < count; i++) {
const delay = `animation-delay: ${i * 0.15}s`;
html += `<div class="skeleton-card">
<div class="skeleton-line skeleton-line-title" style="${delay}"></div>
<div class="skeleton-line skeleton-line-medium" style="${delay}"></div>
<div class="skeleton-line skeleton-line-short" style="${delay}"></div>
<div class="skeleton-actions">
<div class="skeleton-btn" style="${delay}"></div>
<div class="skeleton-btn" style="${delay}"></div>
</div>
</div>`;
}
return `<div class="${gridClass}">${html}</div>`;
}
export class CardSection { export class CardSection {
/** sectionKey: string;
* @param {string} sectionKey Unique key for localStorage persistence titleKey: string;
* @param {object} opts gridClass: string;
* @param {string} opts.titleKey i18n key for the section title addCardOnclick: string;
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid' keyAttr: string;
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card headerExtra: string;
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id') collapsible: boolean;
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons) emptyKey: string;
* @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items bulkActions: BulkAction[] | null;
*/ _filterValue: string;
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey }) { _lastItems: CardItem[] | null;
_dragState: any;
_dragBound: boolean;
_selecting: boolean;
_selected: Set<string>;
_lastClickedKey: string | null;
_escHandler: ((e: KeyboardEvent) => void) | null;
_pendingReconcile: CardItem[] | null;
_animated: boolean;
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
this.sectionKey = sectionKey; this.sectionKey = sectionKey;
this.titleKey = titleKey; this.titleKey = titleKey;
this.gridClass = gridClass; this.gridClass = gridClass;
@@ -56,11 +111,18 @@ export class CardSection {
this.headerExtra = headerExtra || ''; this.headerExtra = headerExtra || '';
this.collapsible = !!collapsible; this.collapsible = !!collapsible;
this.emptyKey = emptyKey || ''; this.emptyKey = emptyKey || '';
this.bulkActions = bulkActions || null;
this._filterValue = ''; this._filterValue = '';
this._lastItems = null; this._lastItems = null;
this._dragState = null; this._dragState = null;
this._dragBound = false; this._dragBound = false;
this._cachedCardRects = null; // Bulk selection state
this._selecting = false;
this._selected = new Set();
this._lastClickedKey = null;
this._escHandler = null;
this._pendingReconcile = null;
this._animated = false;
} }
/** True if this section's DOM element exists (i.e. not the first render). */ /** True if this section's DOM element exists (i.e. not the first render). */
@@ -68,11 +130,8 @@ export class CardSection {
return !!document.querySelector(`[data-card-section="${this.sectionKey}"]`); return !!document.querySelector(`[data-card-section="${this.sectionKey}"]`);
} }
/** /** Returns section HTML string for initial innerHTML building. */
* Returns section HTML string for initial innerHTML building. render(items: CardItem[]) {
* @param {Array<{key: string, html: string}>} items
*/
render(items) {
this._lastItems = items; this._lastItems = items;
this._dragBound = false; // DOM will be recreated → need to re-init drag this._dragBound = false; // DOM will be recreated → need to re-init drag
const count = items.length; const count = items.length;
@@ -98,6 +157,7 @@ export class CardSection {
<span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span> <span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span> <span class="cs-count">${count}</span>
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''} ${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
<div class="cs-filter-wrap"> <div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}" <input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off"> data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
@@ -114,19 +174,19 @@ export class CardSection {
/** Attach event listeners after innerHTML is set. */ /** Attach event listeners after innerHTML is set. */
bind() { bind() {
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`); const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`); const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`) as HTMLInputElement | null;
if (!header || !content) return; if (!header || !content) return;
if (this.collapsible) { if (this.collapsible) {
header.addEventListener('mousedown', (e) => { header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-filter-wrap') || e.target.closest('.cs-header-extra')) return; if ((e.target as HTMLElement).closest('.cs-filter-wrap') || (e.target as HTMLElement).closest('.cs-header-extra')) return;
this._toggleCollapse(header, content); this._toggleCollapse(header, content);
}); });
} }
if (filterInput) { if (filterInput) {
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`); const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
const updateResetVisibility = () => { const updateResetVisibility = () => {
if (resetBtn) resetBtn.style.display = filterInput.value ? '' : 'none'; if (resetBtn) resetBtn.style.display = filterInput.value ? '' : 'none';
}; };
@@ -134,7 +194,7 @@ export class CardSection {
filterInput.addEventListener('mousedown', (e) => e.stopPropagation()); filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
if (resetBtn) resetBtn.addEventListener('mousedown', (e) => e.stopPropagation()); if (resetBtn) resetBtn.addEventListener('mousedown', (e) => e.stopPropagation());
let timer = null; let timer: any = null;
filterInput.addEventListener('input', () => { filterInput.addEventListener('input', () => {
clearTimeout(timer); clearTimeout(timer);
updateResetVisibility(); updateResetVisibility();
@@ -143,7 +203,7 @@ export class CardSection {
this._applyFilter(content, this._filterValue); this._applyFilter(content, this._filterValue);
}, 150); }, 150);
}); });
filterInput.addEventListener('keydown', (e) => { filterInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (filterInput.value) { if (filterInput.value) {
e.stopPropagation(); e.stopPropagation();
@@ -174,6 +234,53 @@ export class CardSection {
updateResetVisibility(); updateResetVisibility();
} }
// Bulk selection toggle button
if (this.bulkActions) {
const bulkBtn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`);
if (bulkBtn) {
bulkBtn.addEventListener('mousedown', (e) => e.stopPropagation());
bulkBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (this._selecting) this.exitSelectionMode();
else this.enterSelectionMode();
});
}
// Card click delegation for selection
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
content.addEventListener('click', (e: MouseEvent) => {
if (!this.keyAttr) return;
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
if (!card) return;
// Don't hijack clicks on buttons, links, inputs inside cards
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
// Auto-enter selection mode on Ctrl/Cmd+Click
if (!this._selecting && (e.ctrlKey || e.metaKey)) {
this.enterSelectionMode();
}
if (!this._selecting) return;
const key = card.getAttribute(this.keyAttr);
if (!key) return;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
});
// Escape to exit selection mode
this._escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this._selecting) {
this.exitSelectionMode();
}
};
document.addEventListener('keydown', this._escHandler);
}
// Tag card elements with their source HTML for future reconciliation // Tag card elements with their source HTML for future reconciliation
this._tagCards(content); this._tagCards(content);
@@ -190,30 +297,26 @@ export class CardSection {
this._cacheSearchText(content); this._cacheSearchText(content);
} }
/** /** Incremental DOM diff — update cards in-place without rebuilding the section. */
* Incremental DOM diff update cards in-place without rebuilding the section. reconcile(items: CardItem[]) {
* @param {Array<{key: string, html: string}>} items
* @returns {{added: Set<string>, replaced: Set<string>, removed: Set<string>}}
*/
reconcile(items) {
// Skip DOM mutations while a drag is in progress — would destroy drag state // Skip DOM mutations while a drag is in progress — would destroy drag state
if (this._dragState) { if (this._dragState) {
this._pendingReconcile = items; this._pendingReconcile = items;
return { added: new Set(), replaced: new Set(), removed: new Set() }; return { added: new Set(), replaced: new Set(), removed: new Set() };
} }
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
if (!content) return { added: new Set(), replaced: new Set(), removed: new Set() }; if (!content) return { added: new Set(), replaced: new Set(), removed: new Set() };
this._lastItems = items; this._lastItems = items;
// Update count badge (will be refined by _applyFilter if a filter is active) // Update count badge (will be refined by _applyFilter if a filter is active)
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`); const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
if (countEl && !this._filterValue) countEl.textContent = items.length; if (countEl && !this._filterValue) countEl.textContent = String(items.length);
// Show/hide empty state // Show/hide empty state
if (this.emptyKey) { if (this.emptyKey) {
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`); let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null;
if (items.length === 0) { if (items.length === 0) {
if (!emptyEl) { if (!emptyEl) {
emptyEl = document.createElement('div'); emptyEl = document.createElement('div');
@@ -232,9 +335,9 @@ export class CardSection {
const newMap = new Map(items.map(i => [i.key, i.html])); const newMap = new Map(items.map(i => [i.key, i.html]));
const addCard = content.querySelector('.cs-add-card'); const addCard = content.querySelector('.cs-add-card');
const added = new Set(); const added = new Set<string>();
const replaced = new Set(); const replaced = new Set<string>();
const removed = new Set(); const removed = new Set<string>();
// Process existing cards: remove or update // Process existing cards: remove or update
if (this.keyAttr) { if (this.keyAttr) {
@@ -243,16 +346,16 @@ export class CardSection {
const key = card.getAttribute(this.keyAttr); const key = card.getAttribute(this.keyAttr);
if (!newMap.has(key)) { if (!newMap.has(key)) {
card.remove(); card.remove();
removed.add(key); removed.add(key!);
} else { } else {
const newHtml = newMap.get(key); const newHtml = newMap.get(key);
if (card._csHtml !== newHtml) { if ((card as any)._csHtml !== newHtml) {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
tmp.innerHTML = newHtml; tmp.innerHTML = newHtml;
const newEl = tmp.firstElementChild; const newEl = tmp.firstElementChild as any;
newEl._csHtml = newHtml; newEl._csHtml = newHtml;
card.replaceWith(newEl); card.replaceWith(newEl);
replaced.add(key); replaced.add(key!);
} }
// else: unchanged — skip // else: unchanged — skip
} }
@@ -266,7 +369,7 @@ export class CardSection {
if (!existingKeys.has(key)) { if (!existingKeys.has(key)) {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
tmp.innerHTML = html; tmp.innerHTML = html;
const newEl = tmp.firstElementChild; const newEl = tmp.firstElementChild as any;
newEl._csHtml = html; newEl._csHtml = html;
if (addCard) content.insertBefore(newEl, addCard); if (addCard) content.insertBefore(newEl, addCard);
else content.appendChild(newEl); else content.appendChild(newEl);
@@ -279,7 +382,7 @@ export class CardSection {
if (added.size > 0 && this.keyAttr && !_reducedMotion.matches) { if (added.size > 0 && this.keyAttr && !_reducedMotion.matches) {
let delay = 0; let delay = 0;
for (const key of added) { for (const key of added) {
const card = content.querySelector(`[${this.keyAttr}="${key}"]`); const card = content.querySelector(`[${this.keyAttr}="${key}"]`) as HTMLElement | null;
if (card) { if (card) {
card.style.animationDelay = `${delay}ms`; card.style.animationDelay = `${delay}ms`;
card.classList.add('card-enter'); card.classList.add('card-enter');
@@ -304,54 +407,34 @@ export class CardSection {
this._applyFilter(content, this._filterValue); this._applyFilter(content, this._filterValue);
} }
// Re-apply bulk selection state after reconcile
if (this._selecting && this.keyAttr) {
// Remove selected keys that were removed from DOM
for (const key of removed) this._selected.delete(key);
// Re-apply .card-selected on surviving cards
for (const key of this._selected) {
const card = content.querySelector(`[${this.keyAttr}="${key}"]`);
if (card) card.classList.add('card-selected');
}
// Inject checkboxes on new/replaced cards
if (added.size > 0 || replaced.size > 0) {
this._injectCheckboxes(content);
}
updateBulkToolbar();
}
return { added, replaced, removed }; return { added, replaced, removed };
} }
/** Bind an array of CardSection instances. */ /** Bind an array of CardSection instances. */
static bindAll(sections) { static bindAll(sections: CardSection[]) {
for (const s of sections) s.bind(); for (const s of sections) s.bind();
} }
/** Expand all given sections. */
static expandAll(sections) {
const map = _getCollapsedMap();
for (const s of sections) {
map[s.sectionKey] = false;
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
if (content) content.style.display = '';
if (section) section.classList.remove('cs-collapsed');
if (header) {
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = 'rotate(90deg)';
}
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
/** Collapse all given sections. */
static collapseAll(sections) {
const map = _getCollapsedMap();
for (const s of sections) {
map[s.sectionKey] = true;
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
if (content) content.style.display = 'none';
if (section) section.classList.add('cs-collapsed');
if (header) {
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = '';
}
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
/** Programmatically expand this section if collapsed. */ /** Programmatically expand this section if collapsed. */
expand() { expand() {
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`); const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
if (!header || !content) return; if (!header || !content) return;
const map = _getCollapsedMap(); const map = _getCollapsedMap();
if (map[this.sectionKey]) { if (map[this.sectionKey]) {
@@ -360,7 +443,7 @@ export class CardSection {
content.style.display = ''; content.style.display = '';
const section = header.closest('[data-card-section]'); const section = header.closest('[data-card-section]');
if (section) section.classList.remove('cs-collapsed'); if (section) section.classList.remove('cs-collapsed');
const chevron = header.querySelector('.cs-chevron'); const chevron = header.querySelector('.cs-chevron') as HTMLElement | null;
if (chevron) chevron.style.transform = 'rotate(90deg)'; if (chevron) chevron.style.transform = 'rotate(90deg)';
} }
} }
@@ -369,38 +452,162 @@ export class CardSection {
* Reorder items array according to saved drag order. * Reorder items array according to saved drag order.
* Call before render() / reconcile(). * Call before render() / reconcile().
*/ */
applySortOrder(items) { applySortOrder(items: CardItem[]) {
if (!this.keyAttr) return items; if (!this.keyAttr) return items;
const order = this._getSavedOrder(); const order = this._getSavedOrder();
if (!order.length) return items; if (!order.length) return items;
const orderMap = new Map(order.map((key, idx) => [key, idx])); const orderMap = new Map(order.map((key: string, idx: number) => [key, idx]));
const sorted = [...items]; const sorted = [...items];
sorted.sort((a, b) => { sorted.sort((a, b) => {
const ia = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity; const ia = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity;
const ib = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity; const ib = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity;
if (ia !== ib) return ia - ib; if (ia !== ib) return (ia as number) - (ib as number);
return 0; // preserve original order for unranked items return 0; // preserve original order for unranked items
}); });
return sorted; return sorted;
} }
// ── Bulk selection ──
enterSelectionMode() {
if (this._selecting) return;
this._selecting = true;
this._selected.clear();
this._lastClickedKey = null;
const section = document.querySelector(`[data-card-section="${this.sectionKey}"]`);
if (section) section.classList.add('cs-selecting');
const btn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`);
if (btn) btn.classList.add('active');
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`) as HTMLElement | null;
if (content) this._injectCheckboxes(content);
showBulkToolbar(this);
}
exitSelectionMode() {
if (!this._selecting) return;
this._selecting = false;
this._selected.clear();
this._lastClickedKey = null;
const section = document.querySelector(`[data-card-section="${this.sectionKey}"]`);
if (section) section.classList.remove('cs-selecting');
const btn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`);
if (btn) btn.classList.remove('active');
// Remove checkboxes and selection classes
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
if (content) {
content.querySelectorAll('.card-bulk-check').forEach(cb => cb.remove());
content.querySelectorAll('.card-selected').forEach(c => c.classList.remove('card-selected'));
}
hideBulkToolbar();
}
_toggleSelect(key: string) {
if (this._selected.has(key)) this._selected.delete(key);
else this._selected.add(key);
this._applySelectionVisuals();
updateBulkToolbar();
}
_selectRange(content: HTMLElement, fromKey: string, toKey: string) {
const cards = [...content.querySelectorAll(`[${this.keyAttr}]`)] as HTMLElement[];
const keys = cards.map(c => c.getAttribute(this.keyAttr)!);
const fromIdx = keys.indexOf(fromKey);
const toIdx = keys.indexOf(toKey);
if (fromIdx < 0 || toIdx < 0) return;
const lo = Math.min(fromIdx, toIdx);
const hi = Math.max(fromIdx, toIdx);
for (let i = lo; i <= hi; i++) {
const card = cards[i];
if (card.style.display !== 'none') { // respect filter
this._selected.add(keys[i]);
}
}
this._applySelectionVisuals();
updateBulkToolbar();
}
selectAll() {
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
if (!content || !this.keyAttr) return;
(content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>).forEach(card => {
if (card.style.display !== 'none') {
this._selected.add(card.getAttribute(this.keyAttr)!);
}
});
this._applySelectionVisuals();
updateBulkToolbar();
}
deselectAll() {
this._selected.clear();
this._applySelectionVisuals();
updateBulkToolbar();
}
_visibleCardCount() {
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
if (!content || !this.keyAttr) return 0;
let count = 0;
(content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>).forEach(card => {
if (card.style.display !== 'none') count++;
});
return count;
}
_applySelectionVisuals() {
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
if (!content || !this.keyAttr) return;
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
const key = card.getAttribute(this.keyAttr);
const selected = this._selected.has(key!);
card.classList.toggle('card-selected', selected);
const cb = card.querySelector('.card-bulk-check') as HTMLInputElement | null;
if (cb) cb.checked = selected;
});
}
_injectCheckboxes(content: HTMLElement) {
if (!this.keyAttr) return;
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
if (card.querySelector('.card-bulk-check')) return;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'card-bulk-check';
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
cb.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
const key = card.getAttribute(this.keyAttr)!;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
});
// Insert as first child of .card-top-actions, or prepend to card
const topActions = card.querySelector('.card-top-actions');
if (topActions) topActions.prepend(cb);
else card.prepend(cb);
});
}
_getSavedOrder() { _getSavedOrder() {
try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; } try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey) as string) || []; }
catch { return []; } catch { return []; }
} }
_saveOrder(keys) { _saveOrder(keys: (string | null)[]) {
localStorage.setItem(ORDER_PREFIX + this.sectionKey, JSON.stringify(keys)); localStorage.setItem(ORDER_PREFIX + this.sectionKey, JSON.stringify(keys));
} }
// ── private ── // ── private ──
_animateEntrance(content) { _animateEntrance(content: HTMLElement) {
if (this._animated) return; if (this._animated) return;
this._animated = true; this._animated = true;
if (_reducedMotion.matches) return; if (_reducedMotion.matches) return;
const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)'; const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)';
const cards = content.querySelectorAll(selector); const cards = content.querySelectorAll(selector) as NodeListOf<HTMLElement>;
cards.forEach((card, i) => { cards.forEach((card, i) => {
card.style.animationDelay = `${i * 30}ms`; card.style.animationDelay = `${i * 30}ms`;
card.classList.add('card-enter'); card.classList.add('card-enter');
@@ -408,25 +615,25 @@ export class CardSection {
}); });
} }
_tagCards(content) { _tagCards(content: HTMLElement) {
if (!this.keyAttr || !this._lastItems) return; if (!this.keyAttr || !this._lastItems) return;
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html])); const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
const cards = content.querySelectorAll(`[${this.keyAttr}]`); const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => { cards.forEach(card => {
const key = card.getAttribute(this.keyAttr); const key = card.getAttribute(this.keyAttr);
if (htmlMap.has(key)) card._csHtml = htmlMap.get(key); if (htmlMap.has(key)) (card as any)._csHtml = htmlMap.get(key);
}); });
} }
/** Cache each card's lowercased text content in data-search for fast filtering. */ /** Cache each card's lowercased text content in data-search for fast filtering. */
_cacheSearchText(content) { _cacheSearchText(content: HTMLElement) {
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)'); const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)') as NodeListOf<HTMLElement>;
cards.forEach(card => { cards.forEach(card => {
card.dataset.search = card.textContent.toLowerCase(); card.dataset.search = card.textContent!.toLowerCase();
}); });
} }
_toggleCollapse(header, content) { _toggleCollapse(header: Element, content: HTMLElement) {
const map = _getCollapsedMap(); const map = _getCollapsedMap();
const nowCollapsed = !map[this.sectionKey]; const nowCollapsed = !map[this.sectionKey];
map[this.sectionKey] = nowCollapsed; map[this.sectionKey] = nowCollapsed;
@@ -435,11 +642,11 @@ export class CardSection {
const section = header.closest('[data-card-section]'); const section = header.closest('[data-card-section]');
if (section) section.classList.toggle('cs-collapsed', nowCollapsed); if (section) section.classList.toggle('cs-collapsed', nowCollapsed);
const chevron = header.querySelector('.cs-chevron'); const chevron = header.querySelector('.cs-chevron') as HTMLElement | null;
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)'; if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
// Cancel any running animation on this content // Cancel any running animation on this content
if (content._csAnim) { content._csAnim.cancel(); content._csAnim = null; } if ((content as any)._csAnim) { (content as any)._csAnim.cancel(); (content as any)._csAnim = null; }
// Skip animation when user prefers reduced motion // Skip animation when user prefers reduced motion
if (_reducedMotion.matches) { if (_reducedMotion.matches) {
@@ -454,11 +661,11 @@ export class CardSection {
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }], [{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 200, easing: 'ease-in-out' } { duration: 200, easing: 'ease-in-out' }
); );
content._csAnim = anim; (content as any)._csAnim = anim;
anim.onfinish = () => { anim.onfinish = () => {
content.style.display = 'none'; content.style.display = 'none';
content.style.overflow = ''; content.style.overflow = '';
content._csAnim = null; (content as any)._csAnim = null;
}; };
} else { } else {
// Intentional forced layout: reading scrollHeight after setting display='' // Intentional forced layout: reading scrollHeight after setting display=''
@@ -470,17 +677,17 @@ export class CardSection {
[{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }], [{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
{ duration: 200, easing: 'ease-in-out' } { duration: 200, easing: 'ease-in-out' }
); );
content._csAnim = anim; (content as any)._csAnim = anim;
anim.onfinish = () => { anim.onfinish = () => {
content.style.overflow = ''; content.style.overflow = '';
content._csAnim = null; (content as any)._csAnim = null;
}; };
} }
} }
_applyFilter(content, query) { _applyFilter(content: HTMLElement, query: string) {
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)'); const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)') as NodeListOf<HTMLElement>;
const addCard = content.querySelector('.cs-add-card'); const addCard = content.querySelector('.cs-add-card') as HTMLElement | null;
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`); const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
const total = cards.length; const total = cards.length;
@@ -488,7 +695,7 @@ export class CardSection {
content.classList.remove('cs-filtering'); content.classList.remove('cs-filtering');
cards.forEach(card => { card.style.display = ''; }); cards.forEach(card => { card.style.display = ''; });
if (addCard) addCard.style.display = ''; if (addCard) addCard.style.display = '';
if (countEl) countEl.textContent = total; if (countEl) countEl.textContent = String(total);
return; return;
} }
content.classList.add('cs-filtering'); content.classList.add('cs-filtering');
@@ -499,7 +706,7 @@ export class CardSection {
let visible = 0; let visible = 0;
cards.forEach(card => { cards.forEach(card => {
const text = card.dataset.search || card.textContent.toLowerCase(); const text = card.dataset.search || card.textContent!.toLowerCase();
// Each group must have at least one matching term (AND of ORs) // Each group must have at least one matching term (AND of ORs)
const match = groups.every(orTerms => orTerms.some(term => text.includes(term))); const match = groups.every(orTerms => orTerms.some(term => text.includes(term)));
card.style.display = match ? '' : 'none'; card.style.display = match ? '' : 'none';
@@ -512,7 +719,7 @@ export class CardSection {
// ── drag-and-drop reordering ── // ── drag-and-drop reordering ──
_injectDragHandles(content) { _injectDragHandles(content: HTMLElement) {
const cards = content.querySelectorAll(`[${this.keyAttr}]`); const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => { cards.forEach(card => {
if (card.querySelector('.card-drag-handle')) return; if (card.querySelector('.card-drag-handle')) return;
@@ -524,15 +731,15 @@ export class CardSection {
}); });
} }
_initDrag(content) { _initDrag(content: HTMLElement) {
if (this._dragBound) return; if (this._dragBound) return;
this._dragBound = true; this._dragBound = true;
content.addEventListener('pointerdown', (e) => { content.addEventListener('pointerdown', (e: PointerEvent) => {
if (this._filterValue) return; if (this._filterValue) return;
const handle = e.target.closest('.card-drag-handle'); const handle = (e.target as HTMLElement).closest('.card-drag-handle');
if (!handle) return; if (!handle) return;
const card = handle.closest(`[${this.keyAttr}]`); const card = handle.closest(`[${this.keyAttr}]`) as HTMLElement | null;
if (!card) return; if (!card) return;
e.preventDefault(); e.preventDefault();
@@ -547,18 +754,20 @@ export class CardSection {
scrollRaf: null, scrollRaf: null,
}; };
const onMove = (ev) => this._onDragMove(ev); const onMove = (ev: PointerEvent) => this._onDragMove(ev);
const onUp = (ev) => { const cleanup = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', cleanup);
this._onDragEnd(ev); document.removeEventListener('pointercancel', cleanup);
this._onDragEnd();
}; };
document.addEventListener('pointermove', onMove); document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp); document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}); });
} }
_onDragMove(e) { _onDragMove(e: PointerEvent) {
const ds = this._dragState; const ds = this._dragState;
if (!ds) return; if (!ds) return;
@@ -573,28 +782,36 @@ export class CardSection {
ds.clone.style.left = (e.clientX - ds.offsetX) + 'px'; ds.clone.style.left = (e.clientX - ds.offsetX) + 'px';
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px'; ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
// Only move placeholder when cursor enters a card's rect // Find which card the pointer is over — only move placeholder on hit
const { card: target, before } = this._getDropTarget(e.clientX, e.clientY, ds.content); const hit = this._hitTestCard(e.clientX, e.clientY, ds);
if (!target) return; // cursor is in a gap — keep placeholder where it is if (hit) {
if (target === ds.lastTarget && before === ds.lastBefore) return; // same position const r = hit.getBoundingClientRect();
ds.lastTarget = target; const before = e.clientX < r.left + r.width / 2;
ds.lastBefore = before; // Track both target card and direction to avoid dead zones at last card
if (hit !== ds._lastHit || before !== ds._lastBefore) {
ds._lastHit = hit;
ds._lastBefore = before;
if (before) { if (before) {
ds.content.insertBefore(ds.placeholder, target); ds.content.insertBefore(ds.placeholder, hit);
} else if (hit.nextElementSibling && hit.nextElementSibling.hasAttribute(this.keyAttr)) {
ds.content.insertBefore(ds.placeholder, hit.nextElementSibling);
} else { } else {
ds.content.insertBefore(ds.placeholder, target.nextSibling); // Last card in grid — insert after it but before add-button
hit.after(ds.placeholder);
}
}
} }
// Auto-scroll near viewport edges // Auto-scroll near viewport edges
this._autoScroll(e.clientY, ds); this._autoScroll(e.clientY, ds);
} }
_startDrag(ds, e) { _startDrag(ds: any, e: PointerEvent) {
ds.started = true; ds.started = true;
const rect = ds.card.getBoundingClientRect(); const rect = ds.card.getBoundingClientRect();
// Clone for visual drag // Clone for visual drag
const clone = ds.card.cloneNode(true); const clone = ds.card.cloneNode(true) as HTMLElement;
clone.className = ds.card.className + ' card-drag-clone'; clone.className = ds.card.className + ' card-drag-clone';
clone.style.width = rect.width + 'px'; clone.style.width = rect.width + 'px';
clone.style.height = rect.height + 'px'; clone.style.height = rect.height + 'px';
@@ -615,17 +832,13 @@ export class CardSection {
// Hide original // Hide original
ds.card.style.display = 'none'; ds.card.style.display = 'none';
ds.content.classList.add('cs-dragging');
document.body.classList.add('cs-drag-active'); document.body.classList.add('cs-drag-active');
// Cache card bounding rects for the duration of the drag
this._cachedCardRects = this._buildCardRectCache(ds.content);
} }
_onDragEnd() { _onDragEnd() {
const ds = this._dragState; const ds = this._dragState;
this._dragState = null; this._dragState = null;
this._cachedCardRects = null;
if (!ds || !ds.started) return; if (!ds || !ds.started) return;
// Cancel auto-scroll // Cancel auto-scroll
@@ -636,7 +849,6 @@ export class CardSection {
ds.card.style.display = ''; ds.card.style.display = '';
ds.placeholder.remove(); ds.placeholder.remove();
ds.clone.remove(); ds.clone.remove();
ds.content.classList.remove('cs-dragging');
document.body.classList.remove('cs-drag-active'); document.body.classList.remove('cs-drag-active');
// Save new order from DOM // Save new order from DOM
@@ -651,32 +863,30 @@ export class CardSection {
} }
} }
_buildCardRectCache(content) { /**
const cards = content.querySelectorAll(`[${this.keyAttr}]`); * Point-in-rect hit test: find which card the pointer is directly over.
const rects = []; * Only triggers placeholder move when cursor is inside a card dragging
* over gaps keeps the placeholder in its last position.
*/
_hitTestCard(x: number, y: number, ds: any): HTMLElement | null {
const cards = ds.content.querySelectorAll(`[${this.keyAttr}]`) as NodeListOf<HTMLElement>;
for (const card of cards) { for (const card of cards) {
if (card === ds.card) continue;
if (card.style.display === 'none') continue; if (card.style.display === 'none') continue;
rects.push({ card, rect: card.getBoundingClientRect() }); const r = card.getBoundingClientRect();
}
return rects;
}
_getDropTarget(x, y, content) {
const rects = this._cachedCardRects || [];
for (const { card, rect: r } of rects) {
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
return { card, before: x < r.left + r.width / 2 }; return card;
} }
} }
return { card: null, before: false }; return null;
} }
_readDomOrder(content) { _readDomOrder(content: HTMLElement) {
return [...content.querySelectorAll(`[${this.keyAttr}]`)] return [...content.querySelectorAll(`[${this.keyAttr}]`)]
.map(el => el.getAttribute(this.keyAttr)); .map(el => el.getAttribute(this.keyAttr));
} }
_autoScroll(clientY, ds) { _autoScroll(clientY: number, ds: any) {
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf); if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const vp = window.innerHeight; const vp = window.innerHeight;
let speed = 0; let speed = 0;

View File

@@ -19,11 +19,11 @@
* @param {number} [opts.maxHwFps] hardware max FPS draws a dashed reference line * @param {number} [opts.maxHwFps] hardware max FPS draws a dashed reference line
* @returns {Chart|null} * @returns {Chart|null}
*/ */
export function createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, opts = {}) { export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) {
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
if (!canvas) return null; if (!canvas) return null;
const datasets = [ const datasets: any[] = [
{ {
data: [...actualHistory], data: [...actualHistory],
borderColor: '#2196F3', borderColor: '#2196F3',

View File

@@ -2,7 +2,7 @@
* Reusable color-picker popover. * Reusable color-picker popover.
* *
* Usage: * Usage:
* import { createColorPicker } from '../core/color-picker.js'; * import { createColorPicker } from '../core/color-picker.ts';
* const html = createColorPicker({ * const html = createColorPicker({
* id: 'my-picker', * id: 'my-picker',
* currentColor: '#4CAF50', * currentColor: '#4CAF50',
@@ -17,7 +17,7 @@
* Call `closeAllColorPickers()` to dismiss any open popover. * Call `closeAllColorPickers()` to dismiss any open popover.
*/ */
import { t } from './i18n.js'; import { t } from './i18n.ts';
const PRESETS = [ const PRESETS = [
'#4CAF50', '#7C4DFF', '#FF6D00', '#4CAF50', '#7C4DFF', '#FF6D00',
@@ -28,7 +28,7 @@ const PRESETS = [
/** /**
* Build the HTML string for a color-picker widget. * Build the HTML string for a color-picker widget.
*/ */
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) { export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }: { id: string; currentColor: string; onPick?: string; anchor?: string; showReset?: boolean; resetColor?: string }) {
const dots = PRESETS.map(c => { const dots = PRESETS.map(c => {
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : ''; const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`; return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
@@ -56,14 +56,14 @@ export function createColorPicker({ id, currentColor, onPick, anchor = 'right',
// -- Global helpers called from onclick attributes -- // -- Global helpers called from onclick attributes --
// Merge any callbacks pre-registered before this module loaded (e.g. accent picker in index.html) // Merge any callbacks pre-registered before this module loaded (e.g. accent picker in index.html)
const _callbacks = Object.assign({}, window._cpCallbacks || {}); const _callbacks: Record<string, (hex: string) => void> = Object.assign({}, window._cpCallbacks || {});
/** Register the callback for a picker id. */ /** Register the callback for a picker id. */
export function registerColorPicker(id, callback) { export function registerColorPicker(id: string, callback: (hex: string) => void) {
_callbacks[id] = callback; _callbacks[id] = callback;
} }
function _rgbToHex(rgb) { function _rgbToHex(rgb: string) {
const m = rgb.match(/\d+/g); const m = rgb.match(/\d+/g);
if (!m || m.length < 3) return rgb; if (!m || m.length < 3) return rgb;
return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
@@ -71,9 +71,9 @@ function _rgbToHex(rgb) {
window._cpToggle = function (id) { window._cpToggle = function (id) {
// Close all other pickers first (and drop their card elevation) // Close all other pickers first (and drop their card elevation)
document.querySelectorAll('.color-picker-popover').forEach(p => { document.querySelectorAll('.color-picker-popover').forEach((p: Element) => {
if (p.id !== `cp-pop-${id}`) { if (p.id !== `cp-pop-${id}`) {
_cpClosePopover(p); _cpClosePopover(p as HTMLElement);
} }
}); });
const pop = document.getElementById(`cp-pop-${id}`); const pop = document.getElementById(`cp-pop-${id}`);
@@ -92,8 +92,8 @@ window._cpToggle = function (id) {
// to avoid any parent overflow/containment/positioning issues. // to avoid any parent overflow/containment/positioning issues.
const isMobile = window.innerWidth <= 768 || ('ontouchstart' in window); const isMobile = window.innerWidth <= 768 || ('ontouchstart' in window);
if (isMobile && pop.parentElement !== document.body) { if (isMobile && pop.parentElement !== document.body) {
pop._cpOrigParent = pop.parentElement; (pop as any)._cpOrigParent = pop.parentElement;
pop._cpOrigNext = pop.nextSibling; (pop as any)._cpOrigNext = pop.nextSibling;
document.body.appendChild(pop); document.body.appendChild(pop);
} }
if (isMobile) { if (isMobile) {
@@ -113,14 +113,15 @@ window._cpToggle = function (id) {
// Mark active dot // Mark active dot
const swatch = document.getElementById(`cp-swatch-${id}`); const swatch = document.getElementById(`cp-swatch-${id}`);
const cur = swatch ? (_rgbToHex(swatch.style.backgroundColor) || swatch.style.background) : ''; const cur = swatch ? (_rgbToHex(swatch.style.backgroundColor) || swatch.style.background) : '';
pop.querySelectorAll('.color-picker-dot').forEach(d => { pop.querySelectorAll('.color-picker-dot').forEach((d: Element) => {
const dHex = _rgbToHex(d.style.backgroundColor || d.style.background); const el = d as HTMLElement;
d.classList.toggle('active', dHex.toLowerCase() === cur.toLowerCase()); const dHex = _rgbToHex(el.style.backgroundColor || el.style.background);
el.classList.toggle('active', dHex.toLowerCase() === cur.toLowerCase());
}); });
}; };
/** Reset popover positioning and close. */ /** Reset popover positioning and close. */
function _cpClosePopover(pop) { function _cpClosePopover(pop: HTMLElement) {
pop.style.display = 'none'; pop.style.display = 'none';
if (pop.classList.contains('cp-fixed')) { if (pop.classList.contains('cp-fixed')) {
pop.classList.remove('cp-fixed'); pop.classList.remove('cp-fixed');
@@ -135,14 +136,17 @@ function _cpClosePopover(pop) {
pop.style.width = ''; pop.style.width = '';
pop.style.zIndex = ''; pop.style.zIndex = '';
// Return popover to its original parent // Return popover to its original parent
if (pop._cpOrigParent) { if ((pop as any)._cpOrigParent) {
pop._cpOrigParent.insertBefore(pop, pop._cpOrigNext || null); (pop as any)._cpOrigParent.insertBefore(pop, (pop as any)._cpOrigNext || null);
delete pop._cpOrigParent; delete (pop as any)._cpOrigParent;
delete pop._cpOrigNext; delete (pop as any)._cpOrigNext;
} }
} }
const card = pop.closest('.card, .template-card'); const card = pop.closest('.card, .template-card');
if (card) card.classList.remove('cp-elevated'); if (card) card.classList.remove('cp-elevated');
// Remove graph color picker overlay (swatch + popover wrapper)
const graphOverlay = pop.closest('.graph-cp-overlay');
if (graphOverlay) graphOverlay.remove();
} }
window._cpPick = function (id, hex) { window._cpPick = function (id, hex) {
@@ -150,14 +154,15 @@ window._cpPick = function (id, hex) {
const swatch = document.getElementById(`cp-swatch-${id}`); const swatch = document.getElementById(`cp-swatch-${id}`);
if (swatch) swatch.style.background = hex; if (swatch) swatch.style.background = hex;
// Update native input // Update native input
const native = document.getElementById(`cp-native-${id}`); const native = document.getElementById(`cp-native-${id}`) as HTMLInputElement | null;
if (native) native.value = hex; if (native) native.value = hex;
// Mark active dot and close // Mark active dot and close
const pop = document.getElementById(`cp-pop-${id}`); const pop = document.getElementById(`cp-pop-${id}`);
if (pop) { if (pop) {
pop.querySelectorAll('.color-picker-dot').forEach(d => { pop.querySelectorAll('.color-picker-dot').forEach((d: Element) => {
const dHex = _rgbToHex(d.style.backgroundColor || d.style.background); const el = d as HTMLElement;
d.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase()); const dHex = _rgbToHex(el.style.backgroundColor || el.style.background);
el.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase());
}); });
_cpClosePopover(pop); _cpClosePopover(pop);
} }
@@ -165,7 +170,7 @@ window._cpPick = function (id, hex) {
if (_callbacks[id]) _callbacks[id](hex); if (_callbacks[id]) _callbacks[id](hex);
}; };
window._cpReset = function (id, resetColor) { window._cpReset = function (id: string, resetColor: string) {
// Reset swatch to neutral color // Reset swatch to neutral color
const swatch = document.getElementById(`cp-swatch-${id}`); const swatch = document.getElementById(`cp-swatch-${id}`);
if (swatch) swatch.style.background = resetColor; if (swatch) swatch.style.background = resetColor;
@@ -180,7 +185,7 @@ window._cpReset = function (id, resetColor) {
}; };
export function closeAllColorPickers() { export function closeAllColorPickers() {
document.querySelectorAll('.color-picker-popover').forEach(p => _cpClosePopover(p)); document.querySelectorAll('.color-picker-popover').forEach(p => _cpClosePopover(p as HTMLElement));
} }
// Close on outside click // Close on outside click

View File

@@ -2,21 +2,21 @@
* Command Palette global search & navigation (Ctrl+K / Cmd+K). * Command Palette global search & navigation (Ctrl+K / Cmd+K).
*/ */
import { fetchWithAuth, escapeHtml } from './api.js'; import { fetchWithAuth, escapeHtml } from './api.ts';
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { navigateToCard } from './navigation.js'; import { navigateToCard } from './navigation.ts';
import { import {
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE, ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
} from './icons.js'; } from './icons.ts';
import { getCardColor } from './card-colors.js'; import { getCardColor } from './card-colors.ts';
import { graphNavigateToNode } from '../features/graph-editor.js'; import { graphNavigateToNode } from '../features/graph-editor.ts';
import { showToast } from './ui.js'; import { showToast } from './ui.ts';
let _isOpen = false; let _isOpen = false;
let _items = []; let _items: any[] = [];
let _filtered = []; let _filtered: any[] = [];
let _selectedIdx = 0; let _selectedIdx = 0;
let _loading = false; let _loading = false;
@@ -28,12 +28,12 @@ const _streamSubTab = {
static_image: { sub: 'static_image', section: 'static-streams' }, static_image: { sub: 'static_image', section: 'static-streams' },
}; };
function _mapEntities(data, mapFn) { function _mapEntities(data: any, mapFn: (item: any) => any) {
if (Array.isArray(data)) return data.map(mapFn).filter(Boolean); if (Array.isArray(data)) return data.map(mapFn).filter(Boolean);
return []; return [];
} }
function _buildItems(results, states = {}) { function _buildItems(results: any[], states: any = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results; const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items = []; const items = [];
@@ -61,8 +61,8 @@ function _buildItems(results, states = {}) {
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■', name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' }); const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
if (resp.ok) showToast(t('device.stopped'), 'success'); if (resp.ok) { showToast(t('device.stopped'), 'success'); }
else showToast(t('target.error.stop_failed'), 'error'); else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.stop_failed'), 'error'); }
}, },
}); });
} else { } else {
@@ -70,8 +70,8 @@ function _buildItems(results, states = {}) {
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶', name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' }); const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
if (resp.ok) showToast(t('device.started'), 'success'); if (resp.ok) { showToast(t('device.started'), 'success'); }
else showToast(t('target.error.start_failed'), 'error'); else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.start_failed'), 'error'); }
}, },
}); });
} }
@@ -92,8 +92,8 @@ function _buildItems(results, states = {}) {
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION, name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' }); const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.disable') + ': ' + a.name, 'success'); if (resp.ok) { showToast(t('search.action.disable') + ': ' + a.name, 'success'); }
else showToast(t('search.action.disable') + ' failed', 'error'); else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.disable') + ' failed'), 'error'); }
}, },
}); });
} else { } else {
@@ -101,8 +101,8 @@ function _buildItems(results, states = {}) {
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION, name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' }); const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.enable') + ': ' + a.name, 'success'); if (resp.ok) { showToast(t('search.action.enable') + ': ' + a.name, 'success'); }
else showToast(t('search.action.enable') + ' failed', 'error'); else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.enable') + ' failed'), 'error'); }
}, },
}); });
} }
@@ -153,8 +153,8 @@ function _buildItems(results, states = {}) {
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡', name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' }); const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
if (resp.ok) showToast(t('scenes.activated'), 'success'); if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
else showToast(t('scenes.error.activate_failed'), 'error'); else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); }
}, },
}); });
}); });
@@ -193,12 +193,12 @@ async function _fetchAllEntities() {
const [statesData, ...results] = await Promise.all([ const [statesData, ...results] = await Promise.all([
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 }) fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
.then(r => r.ok ? r.json() : {}) .then(r => r.ok ? r.json() : {})
.then(data => data.states || {}) .then((data: any) => data.states || {})
.catch(() => ({})), .catch(() => ({})),
..._responseKeys.map(([ep, key]) => ..._responseKeys.map(([ep, key]) =>
fetchWithAuth(ep, { retry: false, timeout: 5000 }) fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
.then(r => r.ok ? r.json() : {}) .then((r: any) => r.ok ? r.json() : {})
.then(data => data[key] || []) .then((data: any) => data[key as string] || [])
.catch(() => [])), .catch(() => [])),
]); ]);
return _buildItems(results, statesData); return _buildItems(results, statesData);
@@ -217,7 +217,7 @@ const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
// ─── Filtering ─── // ─── Filtering ───
function _filterItems(query) { function _filterItems(query: string) {
let result = _items; let result = _items;
if (query) { if (query) {
const lower = query.toLowerCase(); const lower = query.toLowerCase();
@@ -280,7 +280,7 @@ function _render() {
_scrollActive(results); _scrollActive(results);
} }
function _scrollActive(container) { function _scrollActive(container: HTMLElement) {
const active = container.querySelector('.cp-active'); const active = container.querySelector('.cp-active');
if (active) active.scrollIntoView({ block: 'nearest' }); if (active) active.scrollIntoView({ block: 'nearest' });
} }
@@ -293,10 +293,10 @@ export async function openCommandPalette() {
_isOpen = true; _isOpen = true;
_selectedIdx = 0; _selectedIdx = 0;
const overlay = document.getElementById('command-palette'); const overlay = document.getElementById('command-palette')!;
const input = document.getElementById('cp-input'); const input = document.getElementById('cp-input') as HTMLInputElement;
overlay.style.display = ''; overlay.style.display = '';
document.body.classList.add('modal-open'); document.documentElement.classList.add('modal-open');
input.value = ''; input.value = '';
input.placeholder = t('search.placeholder'); input.placeholder = t('search.placeholder');
_loading = true; _loading = true;
@@ -316,9 +316,9 @@ export async function openCommandPalette() {
export function closeCommandPalette() { export function closeCommandPalette() {
if (!_isOpen) return; if (!_isOpen) return;
_isOpen = false; _isOpen = false;
const overlay = document.getElementById('command-palette'); const overlay = document.getElementById('command-palette')!;
overlay.style.display = 'none'; overlay.style.display = 'none';
document.body.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open');
_items = []; _items = [];
_filtered = []; _filtered = [];
} }
@@ -326,13 +326,13 @@ export function closeCommandPalette() {
// ─── Event handlers ─── // ─── Event handlers ───
function _onInput() { function _onInput() {
const input = document.getElementById('cp-input'); const input = document.getElementById('cp-input') as HTMLInputElement;
_filtered = _filterItems(input.value.trim()); _filtered = _filterItems(input.value.trim());
_selectedIdx = 0; _selectedIdx = 0;
_render(); _render();
} }
function _onKeydown(e) { function _onKeydown(e: KeyboardEvent) {
if (!_isOpen) return; if (!_isOpen) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
@@ -352,14 +352,14 @@ function _onKeydown(e) {
} }
} }
function _onClick(e) { function _onClick(e: Event) {
const row = e.target.closest('.cp-result'); const row = (e.target as HTMLElement).closest('.cp-result') as HTMLElement | null;
if (row) { if (row) {
_selectedIdx = parseInt(row.dataset.cpIdx, 10); _selectedIdx = parseInt(row.dataset.cpIdx, 10);
_selectCurrent(); _selectCurrent();
return; return;
} }
if (e.target.classList.contains('cp-backdrop')) { if ((e.target as HTMLElement).classList.contains('cp-backdrop')) {
closeCommandPalette(); closeCommandPalette();
} }
} }
@@ -380,7 +380,7 @@ function _selectCurrent() {
const entityId = item.nav[4]; // last element is always entity ID const entityId = item.nav[4]; // last element is always entity ID
if (graphNavigateToNode(entityId)) return; if (graphNavigateToNode(entityId)) return;
} }
navigateToCard(...item.nav); navigateToCard(...(item.nav as [string, string | null, string | null, string, string]));
} }
// ─── Initialization ─── // ─── Initialization ───
@@ -389,8 +389,8 @@ export function initCommandPalette() {
const overlay = document.getElementById('command-palette'); const overlay = document.getElementById('command-palette');
if (!overlay) return; if (!overlay) return;
const input = document.getElementById('cp-input'); const input = document.getElementById('cp-input')!;
input.addEventListener('input', _onInput); input.addEventListener('input', _onInput);
input.addEventListener('keydown', _onKeydown); input.addEventListener('keydown', _onKeydown as EventListener);
overlay.addEventListener('click', _onClick); overlay.addEventListener('click', _onClick);
} }

View File

@@ -11,7 +11,7 @@ import {
syncClocksCache, automationsCacheObj, scenePresetsCache, syncClocksCache, automationsCacheObj, scenePresetsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache, captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache, patternTemplatesCache,
} from './state.js'; } from './state.ts';
/** Maps entity_type string from the server event to its DataCache instance. */ /** Maps entity_type string from the server event to its DataCache instance. */
const ENTITY_CACHE_MAP = { const ENTITY_CACHE_MAP = {
@@ -70,7 +70,7 @@ function _invalidateAndReload(entityType) {
clearTimeout(_loaderTimers[loader]); clearTimeout(_loaderTimers[loader]);
_loaderTimers[loader] = setTimeout(() => { _loaderTimers[loader] = setTimeout(() => {
delete _loaderTimers[loader]; delete _loaderTimers[loader];
if (typeof window[loader] === 'function') window[loader](); if (typeof (window as any)[loader] === 'function') (window as any)[loader]();
}, _LOADER_DEBOUNCE_MS); }, _LOADER_DEBOUNCE_MS);
} }
}); });

View File

@@ -2,7 +2,7 @@
* Command-palette style entity selector. * Command-palette style entity selector.
* *
* Usage: * Usage:
* import { EntityPalette, EntitySelect } from '../core/entity-palette.js'; * import { EntityPalette, EntitySelect } from '../core/entity-palette.ts';
* *
* // Direct use (promise-based): * // Direct use (promise-based):
* const value = await EntityPalette.pick({ * const value = await EntityPalette.pick({
@@ -24,18 +24,44 @@
* Call sel.refresh() after repopulating the <select>, sel.destroy() to remove. * Call sel.refresh() after repopulating the <select>, sel.destroy() to remove.
*/ */
import { ICON_SEARCH } from './icons.js'; import { ICON_SEARCH } from './icons.ts';
import type { IconSelectItem } from './icon-select.ts';
// ── EntityPalette (singleton modal) ───────────────────────── // ── EntityPalette (singleton modal) ─────────────────────────
let _instance = null; export interface EntitySelectOpts {
target: HTMLSelectElement;
getItems: () => IconSelectItem[];
placeholder?: string;
onChange?: (value: string) => void;
allowNone?: boolean;
noneLabel?: string;
}
interface EntityPalettePickOpts {
items?: IconSelectItem[];
current?: string;
placeholder?: string;
allowNone?: boolean;
noneLabel?: string;
}
let _instance: EntityPalette | null = null;
export class EntityPalette { export class EntityPalette {
/** _overlay: HTMLDivElement;
* Open the palette and return a promise. _input: HTMLInputElement;
* Resolves to selected value (string) or undefined if cancelled. _list: HTMLDivElement;
*/ _resolve: ((value: string | undefined) => void) | null;
static pick(opts) { _items: IconSelectItem[];
_filtered: (IconSelectItem & { _isNone?: boolean })[];
_highlightIdx: number;
_currentValue: string | undefined;
_allowNone: boolean;
_noneLabel: string;
/** Open the palette and return a promise. Resolves to selected value or undefined if cancelled. */
static pick(opts: EntityPalettePickOpts) {
if (!_instance) _instance = new EntityPalette(); if (!_instance) _instance = new EntityPalette();
return _instance._pick(opts); return _instance._pick(opts);
} }
@@ -54,12 +80,15 @@ export class EntityPalette {
`; `;
document.body.appendChild(this._overlay); document.body.appendChild(this._overlay);
this._input = this._overlay.querySelector('.entity-palette-input'); this._input = this._overlay.querySelector('.entity-palette-input') as HTMLInputElement;
this._list = this._overlay.querySelector('.entity-palette-list'); this._list = this._overlay.querySelector('.entity-palette-list') as HTMLDivElement;
this._resolve = null; this._resolve = null;
this._items = []; this._items = [];
this._filtered = []; this._filtered = [];
this._highlightIdx = 0; this._highlightIdx = 0;
this._currentValue = undefined;
this._allowNone = false;
this._noneLabel = '';
this._overlay.addEventListener('click', (e) => { this._overlay.addEventListener('click', (e) => {
if (e.target === this._overlay) this._cancel(); if (e.target === this._overlay) this._cancel();
@@ -68,7 +97,7 @@ export class EntityPalette {
this._input.addEventListener('keydown', (e) => this._onKeyDown(e)); this._input.addEventListener('keydown', (e) => this._onKeyDown(e));
} }
_pick({ items, current, placeholder, allowNone, noneLabel }) { _pick({ items, current, placeholder, allowNone, noneLabel }: EntityPalettePickOpts) {
return new Promise(resolve => { return new Promise(resolve => {
this._resolve = resolve; this._resolve = resolve;
this._items = items || []; this._items = items || [];
@@ -86,8 +115,8 @@ export class EntityPalette {
}); });
} }
_buildFullList() { _buildFullList(): (IconSelectItem & { _isNone?: boolean })[] {
const all = []; const all: (IconSelectItem & { _isNone?: boolean })[] = [];
if (this._allowNone) { if (this._allowNone) {
all.push({ value: '', label: this._noneLabel || '—', icon: '', desc: '', _isNone: true }); all.push({ value: '', label: this._noneLabel || '—', icon: '', desc: '', _isNone: true });
} }
@@ -131,7 +160,7 @@ export class EntityPalette {
// Click handlers // Click handlers
this._list.querySelectorAll('.entity-palette-item').forEach(el => { this._list.querySelectorAll('.entity-palette-item').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
this._select(this._filtered[parseInt(el.dataset.idx)]); this._select(this._filtered[parseInt((el as HTMLElement).dataset.idx!)]);
}); });
}); });
@@ -140,7 +169,7 @@ export class EntityPalette {
if (hl) hl.scrollIntoView({ block: 'nearest' }); if (hl) hl.scrollIntoView({ block: 'nearest' });
} }
_onKeyDown(e) { _onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1); this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
@@ -161,7 +190,7 @@ export class EntityPalette {
} }
} }
_select(item) { _select(item: any) {
this._overlay.classList.remove('open'); this._overlay.classList.remove('open');
if (this._resolve) this._resolve(item.value); if (this._resolve) this._resolve(item.value);
this._resolve = null; this._resolve = null;
@@ -177,16 +206,16 @@ export class EntityPalette {
// ── EntitySelect (wrapper around a <select>) ──────────────── // ── EntitySelect (wrapper around a <select>) ────────────────
export class EntitySelect { export class EntitySelect {
/** _select: HTMLSelectElement;
* @param {Object} opts _getItems: () => IconSelectItem[];
* @param {HTMLSelectElement} opts.target - the <select> to enhance _placeholder: string;
* @param {Function} opts.getItems - () => Array<{value, label, icon?, desc?}> _onChange: ((value: string) => void) | null;
* @param {string} [opts.placeholder] - palette search placeholder _allowNone: boolean;
* @param {Function} [opts.onChange] - called with (value) after selection _noneLabel: string;
* @param {boolean} [opts.allowNone] - show a "None" entry at the top _items: IconSelectItem[];
* @param {string} [opts.noneLabel] - label for the None entry _trigger: HTMLButtonElement;
*/
constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }) { constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }: EntitySelectOpts) {
this._select = target; this._select = target;
this._getItems = getItems; this._getItems = getItems;
this._placeholder = placeholder || ''; this._placeholder = placeholder || '';
@@ -203,7 +232,7 @@ export class EntitySelect {
this._trigger.type = 'button'; this._trigger.type = 'button';
this._trigger.className = 'entity-select-trigger'; this._trigger.className = 'entity-select-trigger';
this._trigger.addEventListener('click', () => this._open()); this._trigger.addEventListener('click', () => this._open());
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling); this._select.parentNode!.insertBefore(this._trigger, this._select.nextSibling);
this._syncTrigger(); this._syncTrigger();
} }
@@ -216,7 +245,7 @@ export class EntitySelect {
placeholder: this._placeholder, placeholder: this._placeholder,
allowNone: this._allowNone, allowNone: this._allowNone,
noneLabel: this._noneLabel, noneLabel: this._noneLabel,
}); }) as string | undefined;
if (value !== undefined) { if (value !== undefined) {
this._select.value = value; this._select.value = value;
this._syncTrigger(); this._syncTrigger();
@@ -248,7 +277,7 @@ export class EntitySelect {
} }
/** Update the value programmatically (no change event). */ /** Update the value programmatically (no change event). */
setValue(value) { setValue(value: string) {
this._select.value = value; this._select.value = value;
this._syncTrigger(); this._syncTrigger();
} }

View File

@@ -9,10 +9,10 @@
* server:device_health_changed device online/offline status change * server:device_health_changed device online/offline status change
*/ */
import { apiKey } from './state.js'; import { apiKey } from './state.ts';
let _ws = null; let _ws: WebSocket | null = null;
let _reconnectTimer = null; let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
const _RECONNECT_MIN = 1000; const _RECONNECT_MIN = 1000;
const _RECONNECT_MAX = 30000; const _RECONNECT_MAX = 30000;

View File

@@ -7,10 +7,10 @@
* parameterised by which filter array, filter definitions, DOM IDs, etc. * parameterised by which filter array, filter definitions, DOM IDs, etc.
*/ */
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { escapeHtml } from './api.js'; import { escapeHtml } from './api.ts';
import { IconSelect } from './icon-select.js'; import { IconSelect } from './icon-select.ts';
import * as P from './icon-paths.js'; import * as P from './icon-paths.ts';
const _FILTER_ICONS = { const _FILTER_ICONS = {
brightness: P.sunDim, brightness: P.sunDim,
@@ -30,22 +30,35 @@ const _FILTER_ICONS = {
export { _FILTER_ICONS }; export { _FILTER_ICONS };
/** export interface FilterListOpts {
* @param {Object} opts getFilters: () => any[];
* @param {Function} opts.getFilters - () => filtersArr (mutable reference) getFilterDefs: () => any[];
* @param {Function} opts.getFilterDefs - () => filterDefs array getFilterName: (filterId: string) => string;
* @param {Function} opts.getFilterName - (filterId) => display name selectId: string;
* @param {string} opts.selectId - DOM id of the <select> for adding filters containerId: string;
* @param {string} opts.containerId - DOM id of the filter list container prefix: string;
* @param {string} opts.prefix - handler prefix for onclick attrs: '' for PP, 'cspt' for CSPT editingIdInputId: string;
* @param {string} opts.editingIdInputId - DOM id of hidden input holding the editing template ID selfRefFilterId: string;
* @param {string} opts.selfRefFilterId - filter_id that should exclude self (e.g. 'filter_template') autoNameFn?: () => void;
* @param {Function} [opts.autoNameFn] - optional callback after add/remove to auto-generate name initDrag?: (containerId: string, filtersArr: any[], rerenderFn: () => void) => void;
* @param {Function} [opts.initDrag] - drag initializer fn(containerId, filtersArr, rerenderFn) initPaletteGrids?: (containerEl: HTMLElement) => void;
* @param {Function} [opts.initPaletteGrids] - palette grid initializer fn(containerEl) }
*/
export class FilterListManager { export class FilterListManager {
constructor(opts) { _getFilters: () => any[];
_getFilterDefs: () => any[];
_getFilterName: (filterId: string) => string;
_selectId: string;
_containerId: string;
_prefix: string;
_editingIdInputId: string;
_selfRefFilterId: string;
_autoNameFn: (() => void) | null;
_initDrag: ((containerId: string, filtersArr: any[], rerenderFn: () => void) => void) | null;
_initPaletteGrids: ((containerEl: HTMLElement) => void) | null;
_iconSelect: IconSelect | null;
constructor(opts: FilterListOpts) {
this._getFilters = opts.getFilters; this._getFilters = opts.getFilters;
this._getFilterDefs = opts.getFilterDefs; this._getFilterDefs = opts.getFilterDefs;
this._getFilterName = opts.getFilterName; this._getFilterName = opts.getFilterName;
@@ -63,12 +76,9 @@ export class FilterListManager {
/** Get the current IconSelect instance (for external access if needed). */ /** Get the current IconSelect instance (for external access if needed). */
get iconSelect() { return this._iconSelect; } get iconSelect() { return this._iconSelect; }
/** /** Populate the filter <select> and attach/update IconSelect grid. */
* Populate the filter <select> and attach/update IconSelect grid. populateSelect(onChangeCallback: (value: string) => void) {
* @param {Function} onChangeCallback - called when user picks a filter from the icon grid const select = document.getElementById(this._selectId) as HTMLSelectElement;
*/
populateSelect(onChangeCallback) {
const select = document.getElementById(this._selectId);
const filterDefs = this._getFilterDefs(); const filterDefs = this._getFilterDefs();
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`; select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = []; const items = [];
@@ -100,7 +110,7 @@ export class FilterListManager {
* Render the filter list into the container. * Render the filter list into the container.
*/ */
render() { render() {
const container = document.getElementById(this._containerId); const container = document.getElementById(this._containerId) as HTMLElement;
const filtersArr = this._getFilters(); const filtersArr = this._getFilters();
const filterDefs = this._getFilterDefs(); const filterDefs = this._getFilterDefs();
@@ -156,7 +166,7 @@ export class FilterListManager {
</label> </label>
</div>`; </div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) { } else if (opt.type === 'select' && Array.isArray(opt.choices)) {
const editingId = document.getElementById(this._editingIdInputId)?.value || ''; const editingId = (document.getElementById(this._editingIdInputId) as HTMLInputElement)?.value || '';
const filteredChoices = (fi.filter_id === this._selfRefFilterId && opt.key === 'template_id' && editingId) const filteredChoices = (fi.filter_id === this._selfRefFilterId && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId) ? opt.choices.filter(c => c.value !== editingId)
: opt.choices; : opt.choices;
@@ -217,7 +227,7 @@ export class FilterListManager {
* Add a filter from the select element into the filters array. * Add a filter from the select element into the filters array.
*/ */
addFromSelect() { addFromSelect() {
const select = document.getElementById(this._selectId); const select = document.getElementById(this._selectId) as HTMLSelectElement;
const filterId = select.value; const filterId = select.value;
if (!filterId) return; if (!filterId) return;
@@ -245,7 +255,7 @@ export class FilterListManager {
/** /**
* Toggle expand/collapse of a filter card. * Toggle expand/collapse of a filter card.
*/ */
toggleExpand(index) { toggleExpand(index: number) {
const filtersArr = this._getFilters(); const filtersArr = this._getFilters();
if (filtersArr[index]) { if (filtersArr[index]) {
filtersArr[index]._expanded = !filtersArr[index]._expanded; filtersArr[index]._expanded = !filtersArr[index]._expanded;
@@ -256,19 +266,15 @@ export class FilterListManager {
/** /**
* Remove a filter at the given index. * Remove a filter at the given index.
*/ */
remove(index) { remove(index: number) {
const filtersArr = this._getFilters(); const filtersArr = this._getFilters();
filtersArr.splice(index, 1); filtersArr.splice(index, 1);
this.render(); this.render();
if (this._autoNameFn) this._autoNameFn(); if (this._autoNameFn) this._autoNameFn();
} }
/** /** Move a filter up or down by swapping with its neighbour. */
* Move a filter up or down by swapping with its neighbour. move(index: number, direction: number) {
* @param {number} index - current index
* @param {number} direction - -1 for up, +1 for down
*/
move(index, direction) {
const filtersArr = this._getFilters(); const filtersArr = this._getFilters();
const newIndex = index + direction; const newIndex = index + direction;
if (newIndex < 0 || newIndex >= filtersArr.length) return; if (newIndex < 0 || newIndex >= filtersArr.length) return;
@@ -282,7 +288,7 @@ export class FilterListManager {
/** /**
* Update a single option value on a filter. * Update a single option value on a filter.
*/ */
updateOption(filterIndex, optionKey, value) { updateOption(filterIndex: number, optionKey: string, value: any) {
const filtersArr = this._getFilters(); const filtersArr = this._getFilters();
const filterDefs = this._getFilterDefs(); const filterDefs = this._getFilterDefs();
if (filtersArr[filterIndex]) { if (filtersArr[filterIndex]) {

View File

@@ -9,13 +9,43 @@ const PAN_DEAD_ZONE = 4; // px before drag starts
const BOUNDS_MARGIN_FACTOR = 0.5; // allow panning half a viewport past data bounds const BOUNDS_MARGIN_FACTOR = 0.5; // allow panning half a viewport past data bounds
export class GraphCanvas { export class GraphCanvas {
/** svg: SVGSVGElement;
* @param {SVGSVGElement} svg root: SVGGElement;
*/ blockPan: boolean;
constructor(svg) {
private _vx: number;
private _vy: number;
private _zoom: number;
private _panning: boolean;
private _panPending: boolean;
private _panStart: { x: number; y: number } | null;
private _panViewStart: { x: number; y: number } | null;
private _listeners: any[];
private _onZoomChange: ((zoom: number) => void) | null;
private _onViewChange: ((viewport: any) => void) | null;
private _justPanned: boolean;
private _bounds: { x: number; y: number; width: number; height: number } | null;
private _zoomVelocity: number;
private _zoomInertiaAnim: number | null;
private _lastWheelX: number;
private _lastWheelY: number;
private _pointers: Map<number, { x: number; y: number }>;
private _pinchStartDist: number;
private _pinchStartZoom: number;
private _pinchMidX: number;
private _pinchMidY: number;
private _lastTapTime: number;
private _lastTapX: number;
private _lastTapY: number;
private _isTouch: boolean;
private _zoomAnim: number | null;
private _resizeObs: ResizeObserver | null;
private _lastSvgRect: DOMRect;
private _panDeadZone: number;
constructor(svg: SVGSVGElement) {
this.svg = svg; this.svg = svg;
/** @type {SVGGElement} */ this.root = svg.querySelector('.graph-root') as SVGGElement;
this.root = svg.querySelector('.graph-root');
this._vx = 0; this._vx = 0;
this._vy = 0; this._vy = 0;
this._zoom = 1; this._zoom = 1;
@@ -46,6 +76,10 @@ export class GraphCanvas {
this._lastTapX = 0; this._lastTapX = 0;
this._lastTapY = 0; this._lastTapY = 0;
this._isTouch = false; this._isTouch = false;
this._zoomAnim = null;
this._resizeObs = null;
this._panDeadZone = PAN_DEAD_ZONE;
this._lastSvgRect = this.svg.getBoundingClientRect();
this._bind(); this._bind();
} }
@@ -57,11 +91,11 @@ export class GraphCanvas {
/** True briefly after a pan gesture ends — use to suppress click-after-pan. */ /** True briefly after a pan gesture ends — use to suppress click-after-pan. */
get wasPanning() { return this._justPanned; } get wasPanning() { return this._justPanned; }
set onZoomChange(fn) { this._onZoomChange = fn; } set onZoomChange(fn: ((zoom: number) => void) | null) { this._onZoomChange = fn; }
set onViewChange(fn) { this._onViewChange = fn; } set onViewChange(fn: ((viewport: any) => void) | null) { this._onViewChange = fn; }
/** Set data bounds for view clamping. */ /** Set data bounds for view clamping. */
setBounds(bounds) { this._bounds = bounds; } setBounds(bounds: { x: number; y: number; width: number; height: number } | null) { this._bounds = bounds; }
/** Get the visible viewport in graph coordinates. */ /** Get the visible viewport in graph coordinates. */
getViewport() { getViewport() {
@@ -75,7 +109,7 @@ export class GraphCanvas {
} }
/** Convert screen (client) coordinates to graph coordinates. */ /** Convert screen (client) coordinates to graph coordinates. */
screenToGraph(sx, sy) { screenToGraph(sx: number, sy: number) {
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
return { return {
x: (sx - r.left) / this._zoom + this._vx, x: (sx - r.left) / this._zoom + this._vx,
@@ -84,7 +118,7 @@ export class GraphCanvas {
} }
/** Set view to center on a point at current zoom. */ /** Set view to center on a point at current zoom. */
panTo(gx, gy, animate = true) { panTo(gx: number, gy: number, animate = true) {
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
this._vx = gx - (r.width / this._zoom) / 2; this._vx = gx - (r.width / this._zoom) / 2;
this._vy = gy - (r.height / this._zoom) / 2; this._vy = gy - (r.height / this._zoom) / 2;
@@ -92,7 +126,7 @@ export class GraphCanvas {
} }
/** Fit all content within the viewport with padding. */ /** Fit all content within the viewport with padding. */
fitAll(bounds, animate = true) { fitAll(bounds: { x: number; y: number; width: number; height: number } | null, animate = true) {
if (!bounds) return; if (!bounds) return;
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
const pad = 60; const pad = 60;
@@ -108,7 +142,7 @@ export class GraphCanvas {
} }
/** Set zoom level centered on screen point. */ /** Set zoom level centered on screen point. */
zoomTo(level, cx, cy) { zoomTo(level: number, cx?: number, cy?: number) {
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
const mx = cx !== undefined ? cx : r.width / 2 + r.left; const mx = cx !== undefined ? cx : r.width / 2 + r.left;
const my = cy !== undefined ? cy : r.height / 2 + r.top; const my = cy !== undefined ? cy : r.height / 2 + r.top;
@@ -126,7 +160,7 @@ export class GraphCanvas {
* Interpolates the view center in graph-space so the target smoothly * Interpolates the view center in graph-space so the target smoothly
* slides to screen center while zoom changes simultaneously. * slides to screen center while zoom changes simultaneously.
*/ */
zoomToPoint(level, gx, gy, duration = 500) { zoomToPoint(level: number, gx: number, gy: number, duration = 500) {
if (this._zoomAnim) cancelAnimationFrame(this._zoomAnim); if (this._zoomAnim) cancelAnimationFrame(this._zoomAnim);
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
@@ -140,7 +174,7 @@ export class GraphCanvas {
const startCy = this._vy + hh / startZoom; const startCy = this._vy + hh / startZoom;
const t0 = performance.now(); const t0 = performance.now();
const step = (now) => { const step = (now: number) => {
const elapsed = now - t0; const elapsed = now - t0;
const p = Math.min(elapsed / duration, 1); const p = Math.min(elapsed / duration, 1);
// Ease-in-out cubic // Ease-in-out cubic
@@ -169,7 +203,7 @@ export class GraphCanvas {
zoomIn() { this._buttonZoomKick(0.06); } zoomIn() { this._buttonZoomKick(0.06); }
zoomOut() { this._buttonZoomKick(-0.06); } zoomOut() { this._buttonZoomKick(-0.06); }
_buttonZoomKick(impulse) { _buttonZoomKick(impulse: number) {
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
const r = this.svg.getBoundingClientRect(); const r = this.svg.getBoundingClientRect();
this._lastWheelX = r.left + r.width / 2; this._lastWheelX = r.left + r.width / 2;
@@ -190,7 +224,7 @@ export class GraphCanvas {
// ── Private ── // ── Private ──
_on(el, ev, fn, opts) { _on(el: any, ev: string, fn: any, opts?: any) {
el.addEventListener(ev, fn, opts); el.addEventListener(ev, fn, opts);
this._listeners.push([el, ev, fn, opts]); this._listeners.push([el, ev, fn, opts]);
} }
@@ -206,7 +240,7 @@ export class GraphCanvas {
this._resizeObs.observe(this.svg); this._resizeObs.observe(this.svg);
this._lastSvgRect = this.svg.getBoundingClientRect(); this._lastSvgRect = this.svg.getBoundingClientRect();
// Prevent default touch actions on the SVG (browser pan/zoom) // Prevent default touch actions on the SVG (browser pan/zoom)
this.svg.style.touchAction = 'none'; (this.svg as any).style.touchAction = 'none';
} }
_onResize() { _onResize() {
@@ -223,7 +257,7 @@ export class GraphCanvas {
this._applyTransform(false); this._applyTransform(false);
} }
_onWheel(e) { _onWheel(e: WheelEvent) {
e.preventDefault(); e.preventDefault();
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
@@ -273,7 +307,7 @@ export class GraphCanvas {
return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }; return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
} }
_onPointerDown(e) { _onPointerDown(e: PointerEvent) {
this._isTouch = e.pointerType === 'touch'; this._isTouch = e.pointerType === 'touch';
const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE; const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE;
@@ -287,7 +321,7 @@ export class GraphCanvas {
this.svg.classList.remove('panning'); this.svg.classList.remove('panning');
this._pinchStartDist = this._pointerDist(); this._pinchStartDist = this._pointerDist();
this._pinchStartZoom = this._zoom; this._pinchStartZoom = this._zoom;
const mid = this._pointerMid(); const mid = this._pointerMid()!;
this._pinchMidX = mid.x; this._pinchMidX = mid.x;
this._pinchMidY = mid.y; this._pinchMidY = mid.y;
this._panStart = { x: mid.x, y: mid.y }; this._panStart = { x: mid.x, y: mid.y };
@@ -307,7 +341,8 @@ export class GraphCanvas {
// Left-click / single touch on SVG background → pending pan // Left-click / single touch on SVG background → pending pan
if (e.button === 0 && !this.blockPan) { if (e.button === 0 && !this.blockPan) {
const onNode = e.target.closest('.graph-node'); const target = e.target as Element;
const onNode = target.closest('.graph-node');
if (!onNode) { if (!onNode) {
this._panPending = true; this._panPending = true;
this._panDeadZone = deadZone; this._panDeadZone = deadZone;
@@ -317,7 +352,7 @@ export class GraphCanvas {
} }
} }
_onPointerMove(e) { _onPointerMove(e: PointerEvent) {
// Update tracked pointer position // Update tracked pointer position
if (this._pointers.has(e.pointerId)) { if (this._pointers.has(e.pointerId)) {
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -328,16 +363,16 @@ export class GraphCanvas {
const dist = this._pointerDist(); const dist = this._pointerDist();
const scale = dist / this._pinchStartDist; const scale = dist / this._pinchStartDist;
const newZoom = this._pinchStartZoom * scale; const newZoom = this._pinchStartZoom * scale;
const mid = this._pointerMid(); const mid = this._pointerMid()!;
// Zoom around pinch midpoint // Zoom around pinch midpoint
this.zoomTo(newZoom, mid.x, mid.y); this.zoomTo(newZoom, mid.x, mid.y);
// Pan with pinch movement // Pan with pinch movement
const dx = (mid.x - this._panStart.x) / this._zoom; const dx = (mid.x - this._panStart!.x) / this._zoom;
const dy = (mid.y - this._panStart.y) / this._zoom; const dy = (mid.y - this._panStart!.y) / this._zoom;
this._vx = this._panViewStart.x - dx; this._vx = this._panViewStart!.x - dx;
this._vy = this._panViewStart.y - dy; this._vy = this._panViewStart!.y - dy;
this._applyTransform(false); this._applyTransform(false);
if (this._onZoomChange) this._onZoomChange(this._zoom); if (this._onZoomChange) this._onZoomChange(this._zoom);
return; return;
@@ -346,8 +381,8 @@ export class GraphCanvas {
// Check dead-zone for pending single-finger pan // Check dead-zone for pending single-finger pan
const dz = this._panDeadZone || PAN_DEAD_ZONE; const dz = this._panDeadZone || PAN_DEAD_ZONE;
if (this._panPending && !this._panning) { if (this._panPending && !this._panning) {
const dx = e.clientX - this._panStart.x; const dx = e.clientX - this._panStart!.x;
const dy = e.clientY - this._panStart.y; const dy = e.clientY - this._panStart!.y;
if (Math.abs(dx) > dz || Math.abs(dy) > dz) { if (Math.abs(dx) > dz || Math.abs(dy) > dz) {
this._panning = true; this._panning = true;
this.svg.classList.add('panning'); this.svg.classList.add('panning');
@@ -356,14 +391,14 @@ export class GraphCanvas {
} }
if (!this._panning) return; if (!this._panning) return;
const dx = (e.clientX - this._panStart.x) / this._zoom; const dx = (e.clientX - this._panStart!.x) / this._zoom;
const dy = (e.clientY - this._panStart.y) / this._zoom; const dy = (e.clientY - this._panStart!.y) / this._zoom;
this._vx = this._panViewStart.x - dx; this._vx = this._panViewStart!.x - dx;
this._vy = this._panViewStart.y - dy; this._vy = this._panViewStart!.y - dy;
this._applyTransform(false); this._applyTransform(false);
} }
_onPointerUp(e) { _onPointerUp(e: PointerEvent) {
this._pointers.delete(e.pointerId); this._pointers.delete(e.pointerId);
// If we were pinching and one finger lifts, reset pinch state // If we were pinching and one finger lifts, reset pinch state
@@ -409,7 +444,7 @@ export class GraphCanvas {
} }
} }
_startPan(e) { _startPan(e: PointerEvent) {
if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
this._panning = true; this._panning = true;
this._panPending = false; this._panPending = false;

View File

@@ -3,11 +3,29 @@
* Supports creating, changing, and detaching connections via the graph editor. * Supports creating, changing, and detaching connections via the graph editor.
*/ */
import { fetchWithAuth } from './api.js'; import { fetchWithAuth } from './api.ts';
import { import {
streamsCache, colorStripSourcesCache, valueSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj, audioSourcesCache, outputTargetsCache, automationsCacheObj,
} from './state.js'; } from './state.ts';
/* ── Types ────────────────────────────────────────────────────── */
interface ConnectionEntry {
targetKind: string;
field: string;
sourceKind: string;
edgeType: string;
endpoint?: string;
cache?: { invalidate(): void };
nested?: boolean;
}
interface CompatibleInput {
targetKind: string;
field: string;
edgeType: string;
}
/** /**
* Connection map: for each (targetKind, field) pair, defines: * Connection map: for each (targetKind, field) pair, defines:
@@ -17,7 +35,7 @@ import {
* - cache: the DataCache to invalidate after update * - cache: the DataCache to invalidate after update
* - nested: true if this field is inside a nested structure (not editable via drag) * - nested: true if this field is inside a nested structure (not editable via drag)
*/ */
const CONNECTION_MAP = [ const CONNECTION_MAP: ConnectionEntry[] = [
// Picture sources // Picture sources
{ targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache }, { targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
{ targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache }, { targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache },
@@ -59,7 +77,7 @@ const CONNECTION_MAP = [
/** /**
* Check if an edge (by field name) is editable via drag-connect. * Check if an edge (by field name) is editable via drag-connect.
*/ */
export function isEditableEdge(field) { export function isEditableEdge(field: string): boolean {
const entry = CONNECTION_MAP.find(c => c.field === field); const entry = CONNECTION_MAP.find(c => c.field === field);
return entry ? !entry.nested : false; return entry ? !entry.nested : false;
} }
@@ -68,7 +86,7 @@ export function isEditableEdge(field) {
* Find the connection mapping for a given target kind and source kind. * Find the connection mapping for a given target kind and source kind.
* Returns the matching entry (or entries) from CONNECTION_MAP. * Returns the matching entry (or entries) from CONNECTION_MAP.
*/ */
export function findConnection(targetKind, sourceKind, edgeType) { export function findConnection(targetKind: string, sourceKind: string, edgeType?: string): ConnectionEntry[] {
return CONNECTION_MAP.filter(c => return CONNECTION_MAP.filter(c =>
!c.nested && !c.nested &&
c.targetKind === targetKind && c.targetKind === targetKind &&
@@ -81,7 +99,7 @@ export function findConnection(targetKind, sourceKind, edgeType) {
* Find compatible input port fields for a given source kind. * Find compatible input port fields for a given source kind.
* Returns array of { targetKind, field, edgeType }. * Returns array of { targetKind, field, edgeType }.
*/ */
export function getCompatibleInputs(sourceKind) { export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
return CONNECTION_MAP return CONNECTION_MAP
.filter(c => !c.nested && c.sourceKind === sourceKind) .filter(c => !c.nested && c.sourceKind === sourceKind)
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType })); .map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
@@ -90,7 +108,7 @@ export function getCompatibleInputs(sourceKind) {
/** /**
* Find the connection entry for a specific edge (by target kind and field). * Find the connection entry for a specific edge (by target kind and field).
*/ */
export function getConnectionByField(targetKind, field) { export function getConnectionByField(targetKind: string, field: string): ConnectionEntry | undefined {
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
} }
@@ -102,7 +120,7 @@ export function getConnectionByField(targetKind, field) {
* @param {string|null} newSourceId - New source ID, or '' to detach * @param {string|null} newSourceId - New source ID, or '' to detach
* @returns {Promise<boolean>} success * @returns {Promise<boolean>} success
*/ */
export async function updateConnection(targetId, targetKind, field, newSourceId) { export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise<boolean> {
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
if (!entry) return false; if (!entry) return false;
@@ -126,7 +144,7 @@ export async function updateConnection(targetId, targetKind, field, newSourceId)
/** /**
* Detach a connection (set field to null via empty-string sentinel). * Detach a connection (set field to null via empty-string sentinel).
*/ */
export async function detachConnection(targetId, targetKind, field) { export async function detachConnection(targetId: string, targetKind: string, field: string): Promise<boolean> {
return updateConnection(targetId, targetKind, field, ''); return updateConnection(targetId, targetKind, field, '');
} }

View File

@@ -4,9 +4,31 @@
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}) { /* ── Types ────────────────────────────────────────────────────── */
interface GraphNodeRect {
x: number;
y: number;
width: number;
height: number;
}
interface GraphEdge {
from: string;
to: string;
type: string;
field?: string;
editable?: boolean;
points?: { x: number; y: number }[] | null;
fromNode?: GraphNodeRect;
toNode?: GraphNodeRect;
fromPortY?: number;
toPortY?: number;
}
function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
const el = document.createElementNS(SVG_NS, tag); const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
return el; return el;
} }
@@ -15,7 +37,7 @@ function svgEl(tag, attrs = {}) {
* @param {SVGGElement} group * @param {SVGGElement} group
* @param {Array} edges - [{from, to, type, points, fromNode, toNode}] * @param {Array} edges - [{from, to, type, points, fromNode, toNode}]
*/ */
export function renderEdges(group, edges) { export function renderEdges(group: SVGGElement, edges: GraphEdge[]): void {
while (group.firstChild) group.firstChild.remove(); while (group.firstChild) group.firstChild.remove();
// Defs for arrowheads // Defs for arrowheads
@@ -32,7 +54,7 @@ export function renderEdges(group, edges) {
} }
} }
function _createArrowMarker(type) { function _createArrowMarker(type: string): SVGElement {
const marker = svgEl('marker', { const marker = svgEl('marker', {
id: `arrow-${type}`, id: `arrow-${type}`,
viewBox: '0 0 10 10', viewBox: '0 0 10 10',
@@ -50,7 +72,7 @@ function _createArrowMarker(type) {
return marker; return marker;
} }
function _renderEdge(edge) { function _renderEdge(edge: GraphEdge): SVGElement {
const { from, to, type, fromNode, toNode, field, editable } = edge; const { from, to, type, fromNode, toNode, field, editable } = edge;
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`; const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
// Always use port-aware bezier — ELK routes without port knowledge so // Always use port-aware bezier — ELK routes without port knowledge so
@@ -78,7 +100,7 @@ function _renderEdge(edge) {
* Convert ELK layout points to SVG path string. * Convert ELK layout points to SVG path string.
* Uses Catmull-Rom-to-Cubic conversion for smooth curves through all points. * Uses Catmull-Rom-to-Cubic conversion for smooth curves through all points.
*/ */
function _pointsToPath(points) { function _pointsToPath(points: { x: number; y: number }[]): string {
if (points.length < 2) return ''; if (points.length < 2) return '';
if (points.length === 2) { if (points.length === 2) {
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
@@ -108,7 +130,7 @@ function _pointsToPath(points) {
/** /**
* Adjust ELK-routed start/end points to match port Y positions. * Adjust ELK-routed start/end points to match port Y positions.
*/ */
function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) { function _adjustEndpoints(points: { x: number; y: number }[], fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY: number | undefined, toPortY: number | undefined): { x: number; y: number }[] {
if (points.length < 2) return points; if (points.length < 2) return points;
const result = points.map(p => ({ ...p })); const result = points.map(p => ({ ...p }));
if (fromPortY != null) { if (fromPortY != null) {
@@ -126,7 +148,7 @@ function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) {
* Fallback bezier when no ELK routing is available. * Fallback bezier when no ELK routing is available.
* Uses port Y offsets when provided, otherwise centers vertically. * Uses port Y offsets when provided, otherwise centers vertically.
*/ */
function _defaultBezier(fromNode, toNode, fromPortY, toPortY) { function _defaultBezier(fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY: number | undefined, toPortY: number | undefined): string {
const x1 = fromNode.x + fromNode.width; const x1 = fromNode.x + fromNode.width;
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2); const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
const x2 = toNode.x; const x2 = toNode.x;
@@ -138,7 +160,7 @@ function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
/** /**
* Highlight edges that connect to a specific node (upstream chain). * Highlight edges that connect to a specific node (upstream chain).
*/ */
export function highlightChain(edgeGroup, nodeId, edges) { export function highlightChain(edgeGroup: SVGGElement, nodeId: string, edges: GraphEdge[]): Set<string> {
// Build adjacency indexes for O(E) BFS instead of O(N*E) // Build adjacency indexes for O(E) BFS instead of O(N*E)
const toIndex = new Map(); // toId → [edge] const toIndex = new Map(); // toId → [edge]
const fromIndex = new Map(); // fromId → [edge] const fromIndex = new Map(); // fromId → [edge]
@@ -150,8 +172,8 @@ export function highlightChain(edgeGroup, nodeId, edges) {
} }
// Find all ancestors recursively // Find all ancestors recursively
const upstream = new Set(); const upstream = new Set<string>();
const stack = [nodeId]; const stack: string[] = [nodeId];
while (stack.length) { while (stack.length) {
const current = stack.pop(); const current = stack.pop();
for (const e of (toIndex.get(current) || [])) { for (const e of (toIndex.get(current) || [])) {
@@ -164,7 +186,7 @@ export function highlightChain(edgeGroup, nodeId, edges) {
upstream.add(nodeId); upstream.add(nodeId);
// Downstream too // Downstream too
const downstream = new Set(); const downstream = new Set<string>();
const dStack = [nodeId]; const dStack = [nodeId];
while (dStack.length) { while (dStack.length) {
const current = dStack.pop(); const current = dStack.pop();
@@ -186,16 +208,26 @@ export function highlightChain(edgeGroup, nodeId, edges) {
path.classList.toggle('dimmed', !inChain); path.classList.toggle('dimmed', !inChain);
}); });
// Dim flow-dot groups on non-chain edges
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
const from = g.getAttribute('data-from');
const to = g.getAttribute('data-to');
(g as SVGElement).style.opacity = (chain.has(from) && chain.has(to)) ? '' : '0.12';
});
return chain; return chain;
} }
/** /**
* Clear all highlight/dim classes from edges. * Clear all highlight/dim classes from edges.
*/ */
export function clearEdgeHighlights(edgeGroup) { export function clearEdgeHighlights(edgeGroup: SVGGElement): void {
edgeGroup.querySelectorAll('.graph-edge').forEach(path => { edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
path.classList.remove('highlighted', 'dimmed'); path.classList.remove('highlighted', 'dimmed');
}); });
edgeGroup.querySelectorAll('.graph-edge-flow').forEach(g => {
(g as SVGElement).style.opacity = '';
});
} }
/* ── Edge colors (matching CSS) ── */ /* ── Edge colors (matching CSS) ── */
@@ -218,7 +250,7 @@ export { EDGE_COLORS };
* Update edge paths connected to a specific node (e.g. after dragging). * Update edge paths connected to a specific node (e.g. after dragging).
* Falls back to default bezier since ELK routing points are no longer valid. * Falls back to default bezier since ELK routing points are no longer valid.
*/ */
export function updateEdgesForNode(group, nodeId, nodeMap, edges) { export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap: Map<string, GraphNodeRect>, edges: GraphEdge[]): void {
for (const edge of edges) { for (const edge of edges) {
if (edge.from !== nodeId && edge.to !== nodeId) continue; if (edge.from !== nodeId && edge.to !== nodeId) continue;
const fromNode = nodeMap.get(edge.from); const fromNode = nodeMap.get(edge.from);
@@ -240,7 +272,7 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
* @param {Array} edges * @param {Array} edges
* @param {Set<string>} runningIds - IDs of currently running nodes * @param {Set<string>} runningIds - IDs of currently running nodes
*/ */
export function renderFlowDots(group, edges, runningIds) { export function renderFlowDots(group: SVGGElement, edges: GraphEdge[], runningIds: Set<string>): void {
// Clear previous flow state // Clear previous flow state
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove()); group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
group.querySelectorAll('.graph-edge.graph-edge-active').forEach(el => el.classList.remove('graph-edge-active')); group.querySelectorAll('.graph-edge.graph-edge-active').forEach(el => el.classList.remove('graph-edge-active'));
@@ -263,7 +295,7 @@ export function renderFlowDots(group, edges, runningIds) {
}); });
// Collect all upstream edges that feed into running nodes (full chain) // Collect all upstream edges that feed into running nodes (full chain)
const activeEdges = new Set(); const activeEdges = new Set<number>();
const visited = new Set(); const visited = new Set();
const stack = [...runningIds]; const stack = [...runningIds];
while (stack.length) { while (stack.length) {
@@ -288,7 +320,7 @@ export function renderFlowDots(group, edges, runningIds) {
const d = pathEl.getAttribute('d'); const d = pathEl.getAttribute('d');
if (!d) continue; if (!d) continue;
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default; const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default;
const flowG = svgEl('g', { class: 'graph-edge-flow' }); const flowG = svgEl('g', { class: 'graph-edge-flow', 'data-from': edge.from, 'data-to': edge.to });
for (const beginFrac of ['0s', '1s']) { for (const beginFrac of ['0s', '1s']) {
const circle = svgEl('circle', { fill: color, opacity: '0.9', r: '2.5' }); const circle = svgEl('circle', { fill: color, opacity: '0.9', r: '2.5' });
const anim = document.createElementNS(SVG_NS, 'animateMotion'); const anim = document.createElementNS(SVG_NS, 'animateMotion');
@@ -306,7 +338,7 @@ export function renderFlowDots(group, edges, runningIds) {
/** /**
* Update flow dot paths for edges connected to a node (after drag). * Update flow dot paths for edges connected to a node (after drag).
*/ */
export function updateFlowDotsForNode(group, nodeId, nodeMap, edges) { export function updateFlowDotsForNode(group: SVGGElement, nodeId: string, nodeMap: Map<string, GraphNodeRect>, edges: GraphEdge[]): void {
// Just remove and let caller re-render if needed; or update paths // Just remove and let caller re-render if needed; or update paths
// For simplicity, remove all flow dots — they'll be re-added on next render cycle // For simplicity, remove all flow dots — they'll be re-added on next render cycle
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove()); group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());

View File

@@ -4,6 +4,58 @@
import ELK from 'elkjs/lib/elk.bundled.js'; import ELK from 'elkjs/lib/elk.bundled.js';
/* ── Types ────────────────────────────────────────────────────── */
interface LayoutNode {
id: string;
kind: string;
name: string;
subtype: string;
tags: string[];
running?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
}
interface LayoutEdge {
from: string;
to: string;
field: string;
label: string;
type: string;
editable: boolean;
}
interface LayoutResult {
nodes: Map<string, LayoutNode>;
edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[];
bounds: { x: number; y: number; width: number; height: number };
}
interface PortSet {
types: string[];
ports: Record<string, number>;
}
interface EntitiesInput {
devices?: any[];
captureTemplates?: any[];
ppTemplates?: any[];
audioTemplates?: any[];
patternTemplates?: any[];
syncClocks?: any[];
pictureSources?: any[];
audioSources?: any[];
valueSources?: any[];
colorStripSources?: any[];
outputTargets?: any[];
scenePresets?: any[];
automations?: any[];
csptTemplates?: any[];
}
const NODE_WIDTH = 190; const NODE_WIDTH = 190;
const NODE_HEIGHT = 56; const NODE_HEIGHT = 56;
@@ -26,7 +78,7 @@ const ELK_OPTIONS = {
* @param {Object} entities - { devices, captureTemplates, pictureSources, ... } * @param {Object} entities - { devices, captureTemplates, pictureSources, ... }
* @returns {Promise<{nodes: Map, edges: Array, bounds: {x,y,width,height}}>} * @returns {Promise<{nodes: Map, edges: Array, bounds: {x,y,width,height}}>}
*/ */
export async function computeLayout(entities) { export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> {
const elk = new ELK(); const elk = new ELK();
const { nodes: nodeList, edges: edgeList } = buildGraph(entities); const { nodes: nodeList, edges: edgeList } = buildGraph(entities);
@@ -72,8 +124,8 @@ export async function computeLayout(entities) {
if (!fromNode || !toNode) continue; if (!fromNode || !toNode) continue;
let points = null; let points = null;
if (layoutEdge?.sections?.[0]) { if ((layoutEdge as any)?.sections?.[0]) {
const sec = layoutEdge.sections[0]; const sec = (layoutEdge as any).sections[0];
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint]; points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
} }
@@ -139,7 +191,7 @@ export const ENTITY_LABELS = {
/* ── Edge type (for CSS class) ── */ /* ── Edge type (for CSS class) ── */
function edgeType(fromKind, toKind, field) { function edgeType(fromKind: string, toKind: string, field: string): string {
if (field === 'clock_id') return 'clock'; if (field === 'clock_id') return 'clock';
if (fromKind === 'device') return 'device'; if (fromKind === 'device') return 'device';
if (fromKind === 'picture_source' || toKind === 'picture_source') return 'picture'; if (fromKind === 'picture_source' || toKind === 'picture_source') return 'picture';
@@ -154,18 +206,18 @@ function edgeType(fromKind, toKind, field) {
/* ── Graph builder ── */ /* ── Graph builder ── */
function buildGraph(e) { function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
const nodes = []; const nodes: LayoutNode[] = [];
const edges = []; const edges: LayoutEdge[] = [];
const nodeIds = new Set(); const nodeIds = new Set<string>();
function addNode(id, kind, name, subtype, extra = {}) { function addNode(id: string, kind: string, name: string, subtype: string, extra: Record<string, any> = {}): void {
if (!id || nodeIds.has(id)) return; if (!id || nodeIds.has(id)) return;
nodeIds.add(id); nodeIds.add(id);
nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra }); nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra });
} }
function addEdge(from, to, field, label = '') { function addEdge(from: string, to: string, field: string, label: string = ''): void {
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return; if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
const type = edgeType( const type = edgeType(
nodes.find(n => n.id === from)?.kind, nodes.find(n => n.id === from)?.kind,
@@ -355,7 +407,7 @@ const PORT_TYPE_ORDER = ['template', 'picture', 'colorstrip', 'value', 'audio',
* Compute input/output port positions on every node from the edge list. * Compute input/output port positions on every node from the edge list.
* Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts). * Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts).
*/ */
export function computePorts(nodeMap, edges) { export function computePorts(nodeMap: Map<string, LayoutNode & { inputPorts?: PortSet; outputPorts?: PortSet; height: number }>, edges: (LayoutEdge & { fromPortY?: number; toPortY?: number })[]): void {
// Collect which port types each node needs (keyed by edge type) // Collect which port types each node needs (keyed by edge type)
const inputTypes = new Map(); // nodeId → Set<edgeType> const inputTypes = new Map(); // nodeId → Set<edgeType>
const outputTypes = new Map(); // nodeId → Set<edgeType> const outputTypes = new Map(); // nodeId → Set<edgeType>
@@ -369,7 +421,7 @@ export function computePorts(nodeMap, edges) {
} }
// Sort port types and assign vertical positions // Sort port types and assign vertical positions
function assignPorts(typeSet, height) { function assignPorts(typeSet: Set<string>, height: number): PortSet {
const types = [...typeSet].sort((a, b) => { const types = [...typeSet].sort((a, b) => {
const ai = PORT_TYPE_ORDER.indexOf(a); const ai = PORT_TYPE_ORDER.indexOf(a);
const bi = PORT_TYPE_ORDER.indexOf(b); const bi = PORT_TYPE_ORDER.indexOf(b);

View File

@@ -2,14 +2,62 @@
* SVG node rendering for the graph editor. * SVG node rendering for the graph editor.
*/ */
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js'; import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.ts';
import { EDGE_COLORS } from './graph-edges.js'; import { EDGE_COLORS } from './graph-edges.ts';
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.js'; import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts';
import { getCardColor, setCardColor } from './card-colors.js'; import { getCardColor, setCardColor } from './card-colors.ts';
import * as P from './icon-paths.js'; import * as P from './icon-paths.ts';
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
/* ── Types ────────────────────────────────────────────────────── */
interface PortInfo {
types: string[];
ports: Record<string, number>;
}
interface GraphNode {
id: string;
kind: string;
name: string;
subtype?: string;
x: number;
y: number;
width: number;
height: number;
running?: boolean;
inputPorts?: PortInfo;
outputPorts?: PortInfo;
}
interface GraphEdge {
from: string;
to: string;
type: string;
field?: string;
}
interface NodeCallbacks {
onNodeClick?: (node: GraphNode, e: MouseEvent) => void;
onNodeDblClick?: (node: GraphNode, e: MouseEvent) => void;
onDeleteNode?: (node: GraphNode) => void;
onEditNode?: (node: GraphNode) => void;
onTestNode?: (node: GraphNode) => void;
onStartStopNode?: (node: GraphNode) => void;
onNotificationTest?: (node: GraphNode) => void;
onCloneNode?: (node: GraphNode) => void;
onActivatePreset?: (node: GraphNode) => void;
}
interface OverlayButton {
icon?: string;
svgPath?: string;
action: string;
cls: string;
scale?: number;
}
// ── Port type → human-readable label ── // ── Port type → human-readable label ──
const PORT_LABELS = { const PORT_LABELS = {
template: 'Template', template: 'Template',
@@ -60,14 +108,14 @@ const SUBTYPE_ICONS = {
output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette }, output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette },
}; };
function svgEl(tag, attrs = {}) { function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
const el = document.createElementNS(SVG_NS, tag); const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
return el; return el;
} }
/** Truncate text to fit within maxWidth (approximate). */ /** Truncate text to fit within maxWidth (approximate). */
function truncate(text, maxChars = 22) { function truncate(text: string, maxChars: number = 22): string {
if (!text) return ''; if (!text) return '';
return text.length > maxChars ? text.slice(0, maxChars - 1) + '\u2026' : text; return text.length > maxChars ? text.slice(0, maxChars - 1) + '\u2026' : text;
} }
@@ -78,7 +126,7 @@ function truncate(text, maxChars = 22) {
* @param {Map} nodeMap - id {id, kind, name, subtype, x, y, width, height, running} * @param {Map} nodeMap - id {id, kind, name, subtype, x, y, width, height, running}
* @param {Object} callbacks - { onNodeClick, onNodeDblClick, onDeleteNode, onEditNode, onTestNode } * @param {Object} callbacks - { onNodeClick, onNodeDblClick, onDeleteNode, onEditNode, onTestNode }
*/ */
export function renderNodes(group, nodeMap, callbacks = {}) { export function renderNodes(group: SVGGElement, nodeMap: Map<string, GraphNode>, callbacks: NodeCallbacks = {}): void {
// Clear existing // Clear existing
while (group.firstChild) group.firstChild.remove(); while (group.firstChild) group.firstChild.remove();
@@ -93,20 +141,20 @@ export function renderNodes(group, nodeMap, callbacks = {}) {
*/ */
/** Return custom color for a node, or null if none set. */ /** Return custom color for a node, or null if none set. */
export function getNodeColor(nodeId) { export function getNodeColor(nodeId: string): string | null {
return getCardColor(nodeId) || null; return getCardColor(nodeId) || null;
} }
/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */ /** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */
export function getNodeDisplayColor(nodeId, kind) { export function getNodeDisplayColor(nodeId: string, kind: string): string {
return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666'; return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666';
} }
/** Open a color picker for a graph node, positioned near the click point. */ /** Open a color picker for a graph node, positioned near the click point. */
function _openNodeColorPicker(node, e) { function _openNodeColorPicker(node: GraphNode, e: MouseEvent): void {
const nodeEl = e.target.closest('.graph-node') || document.querySelector(`.graph-node[data-id="${node.id}"]`); const nodeEl = (e.target as SVGElement).closest('.graph-node') as SVGElement | null || document.querySelector(`.graph-node[data-id="${node.id}"]`) as SVGElement | null;
if (!nodeEl) return; if (!nodeEl) return;
const svg = nodeEl.ownerSVGElement; const svg = (nodeEl as SVGGraphicsElement).ownerSVGElement;
const container = svg?.closest('.graph-container'); const container = svg?.closest('.graph-container');
if (!svg || !container) return; if (!svg || !container) return;
@@ -128,6 +176,7 @@ function _openNodeColorPicker(node, e) {
cpOverlay.innerHTML = createColorPicker({ cpOverlay.innerHTML = createColorPicker({
id: pickerId, id: pickerId,
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666', currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
onPick: null,
anchor: 'left', anchor: 'left',
showReset: true, showReset: true,
resetColor: '#808080', resetColor: '#808080',
@@ -136,8 +185,8 @@ function _openNodeColorPicker(node, e) {
// Register callback to update the bar color // Register callback to update the bar color
registerColorPicker(pickerId, (hex) => { registerColorPicker(pickerId, (hex) => {
const bar = nodeEl.querySelector('.graph-node-color-bar'); const bar = nodeEl.querySelector('.graph-node-color-bar') as SVGElement | null;
const barCover = bar?.nextElementSibling; const barCover = bar?.nextElementSibling as SVGElement | null;
if (bar) { if (bar) {
if (hex) { if (hex) {
bar.setAttribute('fill', hex); bar.setAttribute('fill', hex);
@@ -157,7 +206,7 @@ function _openNodeColorPicker(node, e) {
window._cpToggle(pickerId); window._cpToggle(pickerId);
} }
function renderNode(node, callbacks) { function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
const { id, kind, name, subtype, x, y, width, height, running } = node; const { id, kind, name, subtype, x, y, width, height, running } = node;
let color = getNodeColor(id); let color = getNodeColor(id);
@@ -333,13 +382,13 @@ const TEST_KINDS = new Set([
'color_strip_source', 'cspt', 'color_strip_source', 'cspt',
]); ]);
function _createOverlay(node, nodeWidth, callbacks) { function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallbacks): SVGElement {
const overlay = svgEl('g', { class: 'graph-node-overlay' }); const overlay = svgEl('g', { class: 'graph-node-overlay' });
const btnSize = 24; const btnSize = 24;
const btnGap = 2; const btnGap = 2;
// Build button list dynamically based on node kind/subtype // Build button list dynamically based on node kind/subtype
const btns = []; const btns: OverlayButton[] = [];
// Start/stop button for applicable kinds // Start/stop button for applicable kinds
if (START_STOP_KINDS.has(node.kind)) { if (START_STOP_KINDS.has(node.kind)) {
@@ -453,7 +502,7 @@ function _createOverlay(node, nodeWidth, callbacks) {
* Patch a node's running state in-place without replacing the element. * Patch a node's running state in-place without replacing the element.
* Updates the start/stop button icon/class, running dot, and node CSS class. * Updates the start/stop button icon/class, running dot, and node CSS class.
*/ */
export function patchNodeRunning(group, node) { export function patchNodeRunning(group: SVGGElement, node: GraphNode): void {
const el = group.querySelector(`.graph-node[data-id="${node.id}"]`); const el = group.querySelector(`.graph-node[data-id="${node.id}"]`);
if (!el) return; if (!el) return;
@@ -494,7 +543,7 @@ export function patchNodeRunning(group, node) {
/** /**
* Highlight a single node (add class, scroll to). * Highlight a single node (add class, scroll to).
*/ */
export function highlightNode(group, nodeId, cls = 'search-match') { export function highlightNode(group: SVGGElement, nodeId: string, cls: string = 'search-match'): Element | null {
// Remove existing highlights // Remove existing highlights
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls)); group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`); const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
@@ -505,7 +554,7 @@ export function highlightNode(group, nodeId, cls = 'search-match') {
/** /**
* Mark orphan nodes (no incoming or outgoing edges). * Mark orphan nodes (no incoming or outgoing edges).
*/ */
export function markOrphans(group, nodeMap, edges) { export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>, edges: GraphEdge[]): void {
const connected = new Set(); const connected = new Set();
for (const e of edges) { for (const e of edges) {
connected.add(e.from); connected.add(e.from);
@@ -521,7 +570,7 @@ export function markOrphans(group, nodeMap, edges) {
/** /**
* Update selection state on nodes. * Update selection state on nodes.
*/ */
export function updateSelection(group, selectedIds) { export function updateSelection(group: SVGGElement, selectedIds: Set<string>): void {
group.querySelectorAll('.graph-node').forEach(n => { group.querySelectorAll('.graph-node').forEach(n => {
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id'))); n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id')));
}); });

View File

@@ -30,7 +30,7 @@ function getPluralForm(locale, count) {
return count === 1 ? 'one' : 'other'; return count === 1 ? 'one' : 'other';
} }
export function t(key, params = {}) { export function t(key: string, params: Record<string, any> = {}) {
let text; let text;
if ('count' in params) { if ('count' in params) {
const form = getPluralForm(currentLocale, params.count); const form = getPluralForm(currentLocale, params.count);
@@ -44,7 +44,7 @@ export function t(key, params = {}) {
return text; return text;
} }
async function loadTranslations(locale) { async function loadTranslations(locale: string) {
try { try {
const response = await fetch(`/static/locales/${locale}.json`); const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) { if (!response.ok) {
@@ -71,7 +71,7 @@ export async function initLocale() {
await setLocale(savedLocale); await setLocale(savedLocale);
} }
export async function setLocale(locale) { export async function setLocale(locale: string) {
if (!supportedLocales[locale]) { if (!supportedLocales[locale]) {
locale = 'en'; locale = 'en';
} }
@@ -87,7 +87,7 @@ export async function setLocale(locale) {
} }
export function changeLocale() { export function changeLocale() {
const select = document.getElementById('locale-select'); const select = document.getElementById('locale-select') as HTMLSelectElement;
const newLocale = select.value; const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) { if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale); localStorage.setItem('locale', newLocale);
@@ -96,7 +96,7 @@ export function changeLocale() {
} }
function updateLocaleSelect() { function updateLocaleSelect() {
const select = document.getElementById('locale-select'); const select = document.getElementById('locale-select') as HTMLSelectElement | null;
if (select) { if (select) {
select.value = currentLocale; select.value = currentLocale;
} }
@@ -109,13 +109,13 @@ export function updateAllText() {
}); });
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder'); const key = el.getAttribute('data-i18n-placeholder')!;
el.placeholder = t(key); (el as HTMLInputElement).placeholder = t(key);
}); });
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title'); const key = el.getAttribute('data-i18n-title')!;
el.title = t(key); (el as HTMLElement).title = t(key);
}); });
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => { document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {

View File

@@ -78,3 +78,6 @@ export const cpu = '<rect x="4" y="4" width="16" height="16" rx="2"/><r
export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>'; export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>';
export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>'; export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>';
export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>'; export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>';
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';

View File

@@ -2,7 +2,7 @@
* Reusable icon-grid selector (replaces a plain <select>). * Reusable icon-grid selector (replaces a plain <select>).
* *
* Usage: * Usage:
* import { IconSelect } from '../core/icon-select.js'; * import { IconSelect } from '../core/icon-select.ts';
* *
* const sel = new IconSelect({ * const sel = new IconSelect({
* target: document.getElementById('my-select'), // the <select> to enhance * target: document.getElementById('my-select'), // the <select> to enhance
@@ -18,14 +18,14 @@
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove. * Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
*/ */
import { desktopFocus } from './ui.js'; import { desktopFocus } from './ui.ts';
const POPUP_CLASS = 'icon-select-popup'; const POPUP_CLASS = 'icon-select-popup';
/** Close every open icon-select popup. */ /** Close every open icon-select popup. */
export function closeAllIconSelects() { export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
p.classList.remove('open'); (p as HTMLElement).classList.remove('open');
}); });
} }
@@ -35,7 +35,8 @@ function _ensureGlobalListener() {
if (_globalListenerAdded) return; if (_globalListenerAdded) return;
_globalListenerAdded = true; _globalListenerAdded = true;
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) { const target = e.target as HTMLElement;
if (!target.closest(`.${POPUP_CLASS}`) && !target.closest('.icon-select-trigger')) {
closeAllIconSelects(); closeAllIconSelects();
} }
}); });
@@ -44,15 +45,33 @@ function _ensureGlobalListener() {
}); });
} }
export interface IconSelectItem {
value: string;
icon: string;
label: string;
desc?: string;
}
export interface IconSelectOpts {
target: HTMLSelectElement;
items: IconSelectItem[];
onChange?: (value: string) => void;
columns?: number;
placeholder?: string;
}
export class IconSelect { export class IconSelect {
/** _select: HTMLSelectElement;
* @param {Object} opts _items: IconSelectItem[];
* @param {HTMLSelectElement} opts.target - the <select> element to enhance _onChange: ((value: string) => void) | undefined;
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items _columns: number;
* @param {Function} [opts.onChange] - called with (value) after user picks _placeholder: string;
* @param {number} [opts.columns=2] - grid column count _trigger: HTMLButtonElement;
*/ _popup: HTMLDivElement;
constructor({ target, items, onChange, columns = 2, placeholder = '' }) { _scrollHandler: (() => void) | null = null;
_scrollTargets: (HTMLElement | Window)[] = [];
constructor({ target, items, onChange, columns = 2, placeholder = '' }: IconSelectOpts) {
_ensureGlobalListener(); _ensureGlobalListener();
this._select = target; this._select = target;
@@ -72,7 +91,7 @@ export class IconSelect {
e.stopPropagation(); e.stopPropagation();
this._toggle(); this._toggle();
}); });
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling); this._select.parentNode!.insertBefore(this._trigger, this._select.nextSibling);
// Build popup (portaled to body to avoid overflow clipping) // Build popup (portaled to body to avoid overflow clipping)
this._popup = document.createElement('div'); this._popup = document.createElement('div');
@@ -84,7 +103,7 @@ export class IconSelect {
// Bind item clicks // Bind item clicks
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); this.setValue((cell as HTMLElement).dataset.value!, true);
this._popup.classList.remove('open'); this._popup.classList.remove('open');
this._removeScrollListener(); this._removeScrollListener();
}); });
@@ -121,7 +140,8 @@ export class IconSelect {
} }
// Update active state in grid // Update active state in grid
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.classList.toggle('active', cell.dataset.value === val); const el = cell as HTMLElement;
el.classList.toggle('active', el.dataset.value === val);
}); });
} }
@@ -175,12 +195,13 @@ export class IconSelect {
this._removeScrollListener(); this._removeScrollListener();
}; };
// Listen on capture phase to catch scroll on any ancestor // Listen on capture phase to catch scroll on any ancestor
let el = this._trigger.parentNode; let el: Node | null = this._trigger.parentNode;
this._scrollTargets = []; this._scrollTargets = [];
while (el && el !== document) { while (el && el !== document) {
if (el.scrollHeight > el.clientHeight || el.classList?.contains('modal-content')) { const htmlEl = el as HTMLElement;
el.addEventListener('scroll', this._scrollHandler, { passive: true }); if (htmlEl.scrollHeight > htmlEl.clientHeight || htmlEl.classList?.contains('modal-content')) {
this._scrollTargets.push(el); htmlEl.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollTargets.push(htmlEl);
} }
el = el.parentNode; el = el.parentNode;
} }
@@ -198,7 +219,7 @@ export class IconSelect {
} }
/** Change the value programmatically. */ /** Change the value programmatically. */
setValue(value, fireChange = false) { setValue(value: string, fireChange = false) {
this._select.value = value; this._select.value = value;
this._syncTrigger(); this._syncTrigger();
if (fireChange) { if (fireChange) {
@@ -209,12 +230,12 @@ export class IconSelect {
} }
/** Refresh labels (e.g. after language change). Call with new items array. */ /** Refresh labels (e.g. after language change). Call with new items array. */
updateItems(items) { updateItems(items: IconSelectItem[]) {
this._items = items; this._items = items;
this._popup.innerHTML = this._buildGrid(); this._popup.innerHTML = this._buildGrid();
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); this.setValue((cell as HTMLElement).dataset.value!, true);
this._popup.classList.remove('open'); this._popup.classList.remove('open');
}); });
}); });
@@ -236,13 +257,8 @@ export class IconSelect {
* Displays a centered modal with an icon grid. When the user picks a type, * Displays a centered modal with an icon grid. When the user picks a type,
* the overlay closes and `onPick(value)` is called. Clicking the backdrop * the overlay closes and `onPick(value)` is called. Clicking the backdrop
* or pressing Escape dismisses without picking. * or pressing Escape dismisses without picking.
*
* @param {Object} opts
* @param {string} opts.title - heading text
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items
* @param {Function} opts.onPick - called with the selected value
*/ */
export function showTypePicker({ title, items, onPick }) { export function showTypePicker({ title, items, onPick }: { title: string; items: IconSelectItem[]; onPick: (value: string) => void }) {
const showFilter = items.length > 9; const showFilter = items.length > 9;
// Build cells // Build cells
@@ -269,13 +285,14 @@ export function showTypePicker({ title, items, onPick }) {
// Filter logic // Filter logic
if (showFilter) { if (showFilter) {
const input = overlay.querySelector('.type-picker-filter'); const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
const allCells = overlay.querySelectorAll('.icon-select-cell'); const allCells = overlay.querySelectorAll('.icon-select-cell');
input.addEventListener('input', () => { input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim(); const q = input.value.toLowerCase().trim();
allCells.forEach(cell => { allCells.forEach(cell => {
const match = !q || cell.dataset.search.includes(q); const el = cell as HTMLElement;
cell.classList.toggle('disabled', !match); const match = !q || el.dataset.search!.includes(q);
el.classList.toggle('disabled', !match);
}); });
}); });
// Auto-focus filter after animation (skip on touch devices to avoid keyboard popup) // Auto-focus filter after animation (skip on touch devices to avoid keyboard popup)
@@ -288,7 +305,7 @@ export function showTypePicker({ title, items, onPick }) {
}); });
// Escape key // Escape key
const onKey = (e) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(); if (e.key === 'Escape') close();
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@@ -298,7 +315,7 @@ export function showTypePicker({ title, items, onPick }) {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
if (cell.classList.contains('disabled')) return; if (cell.classList.contains('disabled')) return;
close(); close();
onPick(cell.dataset.value); onPick((cell as HTMLElement).dataset.value!);
}); });
}); });

View File

@@ -8,10 +8,10 @@
* Import icons from this module instead of using inline emoji literals. * Import icons from this module instead of using inline emoji literals.
*/ */
import * as P from './icon-paths.js'; import * as P from './icon-paths.ts';
// ── SVG wrapper ──────────────────────────────────────────── // ── SVG wrapper ────────────────────────────────────────────
const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Type-resolution maps (private) ────────────────────────── // ── Type-resolution maps (private) ──────────────────────────
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) }; const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
@@ -50,42 +50,42 @@ const _audioEngineTypeIcons = { wasapi: _svg(P.volume2), sounddevice: _svg(P.m
// ── Type-resolution getters ───────────────────────────────── // ── Type-resolution getters ─────────────────────────────────
/** Target type → icon (fallback: zap) */ /** Target type → icon (fallback: zap) */
export function getTargetTypeIcon(targetType) { export function getTargetTypeIcon(targetType: string): string {
return _targetTypeIcons[targetType] || _svg(P.zap); return _targetTypeIcons[targetType] || _svg(P.zap);
} }
/** Picture source / stream type → icon (fallback: tv) */ /** Picture source / stream type → icon (fallback: tv) */
export function getPictureSourceIcon(streamType) { export function getPictureSourceIcon(streamType: string): string {
return _pictureSourceTypeIcons[streamType] || _svg(P.tv); return _pictureSourceTypeIcons[streamType] || _svg(P.tv);
} }
/** Color strip source type → icon (fallback: film) */ /** Color strip source type → icon (fallback: film) */
export function getColorStripIcon(sourceType) { export function getColorStripIcon(sourceType: string): string {
return _colorStripTypeIcons[sourceType] || _svg(P.film); return _colorStripTypeIcons[sourceType] || _svg(P.film);
} }
/** Value source type → icon (fallback: sliders) */ /** Value source type → icon (fallback: sliders) */
export function getValueSourceIcon(sourceType) { export function getValueSourceIcon(sourceType: string): string {
return _valueSourceTypeIcons[sourceType] || _svg(P.slidersHorizontal); return _valueSourceTypeIcons[sourceType] || _svg(P.slidersHorizontal);
} }
/** Audio source type → icon (fallback: music) */ /** Audio source type → icon (fallback: music) */
export function getAudioSourceIcon(sourceType) { export function getAudioSourceIcon(sourceType: string): string {
return _audioSourceTypeIcons[sourceType] || _svg(P.music); return _audioSourceTypeIcons[sourceType] || _svg(P.music);
} }
/** Device type → icon (fallback: lightbulb) */ /** Device type → icon (fallback: lightbulb) */
export function getDeviceTypeIcon(deviceType) { export function getDeviceTypeIcon(deviceType: string): string {
return _deviceTypeIcons[deviceType] || _svg(P.lightbulb); return _deviceTypeIcons[deviceType] || _svg(P.lightbulb);
} }
/** Capture engine type → icon (fallback: rocket) */ /** Capture engine type → icon (fallback: rocket) */
export function getEngineIcon(engineType) { export function getEngineIcon(engineType: string): string {
return _engineTypeIcons[engineType] || _svg(P.rocket); return _engineTypeIcons[engineType] || _svg(P.rocket);
} }
/** Audio engine type → icon (fallback: music) */ /** Audio engine type → icon (fallback: music) */
export function getAudioEngineIcon(engineType) { export function getAudioEngineIcon(engineType: string): string {
return _audioEngineTypeIcons[engineType] || _svg(P.music); return _audioEngineTypeIcons[engineType] || _svg(P.music);
} }
@@ -179,3 +179,6 @@ export const ICON_CPU = _svg(P.cpu);
export const ICON_KEYBOARD = _svg(P.keyboard); export const ICON_KEYBOARD = _svg(P.keyboard);
export const ICON_MOUSE = _svg(P.mouse); export const ICON_MOUSE = _svg(P.mouse);
export const ICON_HEADPHONES = _svg(P.headphones); export const ICON_HEADPHONES = _svg(P.headphones);
export const ICON_TRASH = _svg(P.trash2);
export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff);

View File

@@ -5,15 +5,23 @@
* Dirty-check: class MyModal extends Modal { snapshotValues() { ... } } * Dirty-check: class MyModal extends Modal { snapshotValues() { ... } }
*/ */
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.js'; import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts';
export class Modal { export class Modal {
static _stack = []; static _stack: Modal[] = [];
constructor(elementId, { backdrop = true, lock = true } = {}) { el: HTMLElement | null;
errorEl: HTMLElement | null;
_lock: boolean;
_backdrop: boolean;
_initialValues: Record<string, any>;
_closing: boolean;
_previousFocus: Element | null = null;
constructor(elementId: string, { backdrop = true, lock = true } = {}) {
this.el = document.getElementById(elementId); this.el = document.getElementById(elementId);
this.errorEl = this.el?.querySelector('.modal-error'); this.errorEl = this.el?.querySelector('.modal-error') as HTMLElement | null;
this._lock = lock; this._lock = lock;
this._backdrop = backdrop; this._backdrop = backdrop;
this._initialValues = {}; this._initialValues = {};
@@ -26,24 +34,33 @@ export class Modal {
open() { open() {
this._previousFocus = document.activeElement; this._previousFocus = document.activeElement;
this.el.style.display = 'flex'; this.el!.style.display = 'flex';
if (this._lock) lockBody(); if (this._lock) lockBody();
if (this._backdrop) setupBackdropClose(this.el, () => this.close()); if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
trapFocus(this.el); trapFocus(this.el!);
Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack = Modal._stack.filter(m => m !== this);
Modal._stack.push(this); Modal._stack.push(this);
// Auto-focus first visible input (skip on touch to avoid virtual keyboard)
if (!isTouchDevice()) {
requestAnimationFrame(() => {
const input = this.el!.querySelector(
'.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])'
) as HTMLElement | null;
if (input && input.offsetParent !== null) input.focus();
});
}
} }
forceClose() { forceClose() {
releaseFocus(this.el); releaseFocus(this.el!);
this.el.style.display = 'none'; this.el!.style.display = 'none';
if (this._lock) unlockBody(); if (this._lock) unlockBody();
this._initialValues = {}; this._initialValues = {};
this.hideError(); this.hideError();
this.onForceClose(); this.onForceClose();
Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack = Modal._stack.filter(m => m !== this);
if (this._previousFocus && typeof this._previousFocus.focus === 'function') { if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
this._previousFocus.focus(); (this._previousFocus as HTMLElement).focus({ preventScroll: true });
this._previousFocus = null; this._previousFocus = null;
} }
} }
@@ -74,7 +91,7 @@ export class Modal {
return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]); return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
} }
showError(msg) { showError(msg: string) {
if (this.errorEl) { if (this.errorEl) {
this.errorEl.textContent = msg; this.errorEl.textContent = msg;
this.errorEl.style.display = 'block'; this.errorEl.style.display = 'block';
@@ -88,7 +105,7 @@ export class Modal {
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */ /** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
onForceClose() {} onForceClose() {}
$(id) { $(id: string) {
return document.getElementById(id); return document.getElementById(id);
} }

View File

@@ -2,7 +2,7 @@
* Cross-entity navigation navigate to a specific card on any tab/subtab. * Cross-entity navigation navigate to a specific card on any tab/subtab.
*/ */
import { switchTab } from '../features/tabs.js'; import { switchTab } from '../features/tabs.ts';
/** /**
* Navigate to a card on any tab/subtab, expanding the section and scrolling to it. * Navigate to a card on any tab/subtab, expanding the section and scrolling to it.
@@ -13,7 +13,7 @@ import { switchTab } from '../features/tabs.js';
* @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id') * @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id')
* @param {string} cardValue Value of the data attribute * @param {string} cardValue Value of the data attribute
*/ */
export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) { export function navigateToCard(tab: string, subTab: string | null, sectionKey: string | null, cardAttr: string, cardValue: string) {
// Push current location to history so browser back returns here // Push current location to history so browser back returns here
history.pushState(null, '', location.hash || '#'); history.pushState(null, '', location.hash || '#');
@@ -34,11 +34,11 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
// Expand section if collapsed // Expand section if collapsed
if (sectionKey) { if (sectionKey) {
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`); const content = document.querySelector(`[data-cs-content="${sectionKey}"]`) as HTMLElement | null;
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`); const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
if (content && content.style.display === 'none') { if (content && content.style.display === 'none') {
content.style.display = ''; content.style.display = '';
const chevron = header?.querySelector('.cs-chevron'); const chevron = header?.querySelector('.cs-chevron') as HTMLElement | null;
if (chevron) chevron.style.transform = 'rotate(90deg)'; if (chevron) chevron.style.transform = 'rotate(90deg)';
const map = JSON.parse(localStorage.getItem('sections_collapsed') || '{}'); const map = JSON.parse(localStorage.getItem('sections_collapsed') || '{}');
map[sectionKey] = false; map[sectionKey] = false;
@@ -60,17 +60,17 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
// Card not in DOM — trigger data load and wait for it to appear // Card not in DOM — trigger data load and wait for it to appear
_triggerTabLoad(tab); _triggerTabLoad(tab);
_waitForCard(cardAttr, cardValue, 5000, scope).then(card => { _waitForCard(cardAttr, cardValue, 5000, scope).then((card: any) => {
if (card) _highlightCard(card); if (card) _highlightCard(card);
}); });
}); });
} }
let _highlightTimer = 0; let _highlightTimer: ReturnType<typeof setTimeout> | 0 = 0;
let _overlayTimer = 0; let _overlayTimer: ReturnType<typeof setTimeout> | 0 = 0;
let _prevCard = null; let _prevCard: Element | null = null;
function _highlightCard(card) { function _highlightCard(card: Element) {
// Clear previous highlight if still active // Clear previous highlight if still active
if (_prevCard) _prevCard.classList.remove('card-highlight'); if (_prevCard) _prevCard.classList.remove('card-highlight');
clearTimeout(_highlightTimer); clearTimeout(_highlightTimer);
@@ -86,14 +86,14 @@ function _highlightCard(card) {
} }
/** Trigger the tab's data load function (used when card wasn't found in DOM). */ /** Trigger the tab's data load function (used when card wasn't found in DOM). */
function _triggerTabLoad(tab) { function _triggerTabLoad(tab: string) {
if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard(); if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard();
else if (tab === 'automations' && typeof window.loadAutomations === 'function') window.loadAutomations(); else if (tab === 'automations' && typeof window.loadAutomations === 'function') window.loadAutomations();
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources(); else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} }
function _showDimOverlay(duration) { function _showDimOverlay(duration: number) {
clearTimeout(_overlayTimer); clearTimeout(_overlayTimer);
let overlay = document.getElementById('nav-dim-overlay'); let overlay = document.getElementById('nav-dim-overlay');
if (!overlay) { if (!overlay) {
@@ -106,8 +106,8 @@ function _showDimOverlay(duration) {
_overlayTimer = setTimeout(() => overlay.classList.remove('active'), duration); _overlayTimer = setTimeout(() => overlay.classList.remove('active'), duration);
} }
function _waitForCard(cardAttr, cardValue, timeout, scope = document) { function _waitForCard(cardAttr: string, cardValue: string, timeout: number, scope: Document | HTMLElement = document) {
const root = scope === document ? document.body : scope; const root = scope === document ? document.body : scope as HTMLElement;
return new Promise(resolve => { return new Promise(resolve => {
const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`); const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (card) { resolve(card); return; } if (card) { resolve(card); return; }

View File

@@ -3,7 +3,7 @@
* and adding them to a textarea (one app per line). * and adding them to a textarea (one app per line).
* *
* Usage: * Usage:
* import { attachProcessPicker } from '../core/process-picker.js'; * import { attachProcessPicker } from '../core/process-picker.ts';
* attachProcessPicker(containerEl, textareaEl); * attachProcessPicker(containerEl, textareaEl);
* *
* The container must already contain: * The container must already contain:
@@ -14,11 +14,11 @@
* </div> * </div>
*/ */
import { fetchWithAuth } from './api.js'; import { fetchWithAuth } from './api.ts';
import { t } from './i18n.js'; import { t } from './i18n.ts';
import { escapeHtml } from './api.js'; import { escapeHtml } from './api.ts';
function renderList(picker, processes, existing) { function renderList(picker: any, processes: string[], existing: Set<string>): void {
const listEl = picker.querySelector('.process-picker-list'); const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) { if (processes.length === 0) {
listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`; listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`;
@@ -42,7 +42,7 @@ function renderList(picker, processes, existing) {
}); });
} }
async function toggle(picker) { async function toggle(picker: any): Promise<void> {
if (picker.style.display !== 'none') { if (picker.style.display !== 'none') {
picker.style.display = 'none'; picker.style.display = 'none';
return; return;
@@ -59,12 +59,12 @@ async function toggle(picker) {
if (!resp.ok) throw new Error('Failed to fetch processes'); if (!resp.ok) throw new Error('Failed to fetch processes');
const data = await resp.json(); const data = await resp.json();
const existing = new Set( const existing = new Set<string>(
picker._textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean) (picker as any)._textarea.value.split('\n').map((a: string) => a.trim().toLowerCase()).filter(Boolean)
); );
picker._processes = data.processes; (picker as any)._processes = data.processes;
picker._existing = existing; (picker as any)._existing = existing;
renderList(picker, data.processes, existing); renderList(picker, data.processes, existing);
searchEl.focus(); searchEl.focus();
} catch (e) { } catch (e) {
@@ -72,7 +72,7 @@ async function toggle(picker) {
} }
} }
function filter(picker) { function filter(picker: any): void {
const query = picker.querySelector('.process-picker-search').value.toLowerCase(); const query = picker.querySelector('.process-picker-search').value.toLowerCase();
const filtered = (picker._processes || []).filter(p => p.includes(query)); const filtered = (picker._processes || []).filter(p => p.includes(query));
renderList(picker, filtered, picker._existing || new Set()); renderList(picker, filtered, picker._existing || new Set());
@@ -82,12 +82,12 @@ function filter(picker) {
* Wire up a process picker inside `containerEl` to feed into `textareaEl`. * Wire up a process picker inside `containerEl` to feed into `textareaEl`.
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search. * containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search.
*/ */
export function attachProcessPicker(containerEl, textareaEl) { export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
const browseBtn = containerEl.querySelector('.btn-browse-apps'); const browseBtn = containerEl.querySelector('.btn-browse-apps');
const picker = containerEl.querySelector('.process-picker'); const picker = containerEl.querySelector('.process-picker');
if (!browseBtn || !picker) return; if (!browseBtn || !picker) return;
picker._textarea = textareaEl; (picker as any)._textarea = textareaEl;
browseBtn.addEventListener('click', () => toggle(picker)); browseBtn.addEventListener('click', () => toggle(picker));
const searchInput = picker.querySelector('.process-picker-search'); const searchInput = picker.querySelector('.process-picker-search');

View File

@@ -1,313 +0,0 @@
/**
* Shared mutable state — all global variables live here.
*
* ES module `export let` creates live bindings: importers always see
* the latest value. But importers cannot reassign, so every variable
* gets a setter function.
*/
import { DataCache } from './cache.js';
export let apiKey = null;
export function setApiKey(v) { apiKey = v; }
export let refreshInterval = null;
export function setRefreshInterval(v) { refreshInterval = v; }
export let kcTestAutoRefresh = null;
export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; }
export let kcTestTargetId = null;
export function setKcTestTargetId(v) { kcTestTargetId = v; }
export let kcTestWs = null;
export function setKcTestWs(v) { kcTestWs = v; }
export let kcTestFps = 3;
export function setKcTestFps(v) { kcTestFps = v; }
export let _cachedDisplays = null;
export let _displayPickerCallback = null;
export function set_displayPickerCallback(v) { _displayPickerCallback = v; }
export let _displayPickerSelectedIndex = null;
export function set_displayPickerSelectedIndex(v) { _displayPickerSelectedIndex = v; }
// Calibration
export const calibrationTestState = {};
export const EDGE_TEST_COLORS = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Track logged errors to avoid console spam
export const loggedErrors = new Map();
// Device brightness cache
export let _deviceBrightnessCache = {};
export function set_deviceBrightnessCache(v) { _deviceBrightnessCache = v; }
export function updateDeviceBrightness(deviceId, value) {
_deviceBrightnessCache = { ..._deviceBrightnessCache, [deviceId]: value };
}
// Discovery state
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v) { _discoveryScanRunning = v; }
export let _discoveryCache = {};
export function set_discoveryCache(v) { _discoveryCache = v; }
// Streams / templates state
export let _cachedStreams = [];
export let _cachedPPTemplates = [];
export let _cachedCaptureTemplates = [];
export let _availableFilters = [];
export let availableEngines = [];
export function setAvailableEngines(v) { availableEngines = v; }
export let currentEditingTemplateId = null;
export function setCurrentEditingTemplateId(v) { currentEditingTemplateId = v; }
export let _streamNameManuallyEdited = false;
export function set_streamNameManuallyEdited(v) { _streamNameManuallyEdited = v; }
export let _streamModalPPTemplates = [];
export function set_streamModalPPTemplates(v) { _streamModalPPTemplates = v; }
export let _templateNameManuallyEdited = false;
export function set_templateNameManuallyEdited(v) { _templateNameManuallyEdited = v; }
// PP template state
export let _modalFilters = [];
export function set_modalFilters(v) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; }
// CSPT (Color Strip Processing Template) state
export let _csptModalFilters = [];
export function set_csptModalFilters(v) { _csptModalFilters = v; }
export let _csptNameManuallyEdited = false;
export function set_csptNameManuallyEdited(v) { _csptNameManuallyEdited = v; }
export let _stripFilters = [];
export let _cachedCSPTemplates = [];
// Stream test state
export let currentTestingTemplate = null;
export function setCurrentTestingTemplate(v) { currentTestingTemplate = v; }
export let _currentTestStreamId = null;
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }
export let _currentTestPPTemplateId = null;
export function set_currentTestPPTemplateId(v) { _currentTestPPTemplateId = v; }
export let _lastValidatedImageSource = '';
export function set_lastValidatedImageSource(v) { _lastValidatedImageSource = v; }
// Target editor state
export let _targetEditorDevices = [];
export function set_targetEditorDevices(v) { _targetEditorDevices = v; }
// KC editor state
export let _kcNameManuallyEdited = false;
export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; }
// KC WebSockets
export const kcWebSockets = {};
// LED Preview WebSockets
export const ledPreviewWebSockets = {};
// Tutorial state
export let activeTutorial = null;
export function setActiveTutorial(v) { activeTutorial = v; }
// Confirm modal
export let confirmResolve = null;
export function setConfirmResolve(v) { confirmResolve = v; }
// Loading guards
export let _dashboardLoading = false;
export function set_dashboardLoading(v) { _dashboardLoading = v; }
export let _sourcesLoading = false;
export function set_sourcesLoading(v) { _sourcesLoading = v; }
export let _automationsLoading = false;
export function set_automationsLoading(v) { _automationsLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval';
const _POLL_DEFAULT = 2000;
export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY), 10) || _POLL_DEFAULT;
export function setDashboardPollInterval(v) {
dashboardPollInterval = v;
localStorage.setItem(_POLL_KEY, String(v));
}
// Pattern template editor state
export let patternEditorRects = [];
export function setPatternEditorRects(v) { patternEditorRects = v; }
export let patternEditorSelectedIdx = -1;
export function setPatternEditorSelectedIdx(v) { patternEditorSelectedIdx = v; }
export let patternEditorBgImage = null;
export function setPatternEditorBgImage(v) { patternEditorBgImage = v; }
export let patternCanvasDragMode = null;
export function setPatternCanvasDragMode(v) { patternCanvasDragMode = v; }
export let patternCanvasDragStart = null;
export function setPatternCanvasDragStart(v) { patternCanvasDragStart = v; }
export let patternCanvasDragOrigRect = null;
export function setPatternCanvasDragOrigRect(v) { patternCanvasDragOrigRect = v; }
export let patternEditorHoveredIdx = -1;
export function setPatternEditorHoveredIdx(v) { patternEditorHoveredIdx = v; }
export let patternEditorHoverHit = null;
export function setPatternEditorHoverHit(v) { patternEditorHoverHit = v; }
export const PATTERN_RECT_COLORS = [
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
];
export const PATTERN_RECT_BORDERS = [
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
];
// Audio sources
export let _cachedAudioSources = [];
export let _cachedAudioTemplates = [];
export let availableAudioEngines = [];
export function setAvailableAudioEngines(v) { availableAudioEngines = v; }
export let currentEditingAudioTemplateId = null;
export function setCurrentEditingAudioTemplateId(v) { currentEditingAudioTemplateId = v; }
export let _audioTemplateNameManuallyEdited = false;
export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManuallyEdited = v; }
// Value sources
export let _cachedValueSources = [];
// Sync clocks
export let _cachedSyncClocks = [];
// Automations
export let _automationsCache = null;
// ─── DataCache instances ───────────────────────────────────────────
// Each cache syncs its data into the existing `export let` variable
// via a subscriber, preserving backward compatibility.
export const displaysCache = new DataCache({
endpoint: '/config/displays',
extractData: json => json.displays || [],
defaultValue: null,
});
displaysCache.subscribe(v => { _cachedDisplays = v; });
export const streamsCache = new DataCache({
endpoint: '/picture-sources',
extractData: json => json.streams || [],
});
streamsCache.subscribe(v => { _cachedStreams = v; });
export const ppTemplatesCache = new DataCache({
endpoint: '/postprocessing-templates',
extractData: json => json.templates || [],
});
ppTemplatesCache.subscribe(v => { _cachedPPTemplates = v; });
export const captureTemplatesCache = new DataCache({
endpoint: '/capture-templates',
extractData: json => json.templates || [],
});
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
export const audioSourcesCache = new DataCache({
endpoint: '/audio-sources',
extractData: json => json.sources || [],
});
audioSourcesCache.subscribe(v => { _cachedAudioSources = v; });
export const audioTemplatesCache = new DataCache({
endpoint: '/audio-templates',
extractData: json => json.templates || [],
});
audioTemplatesCache.subscribe(v => { _cachedAudioTemplates = v; });
export const valueSourcesCache = new DataCache({
endpoint: '/value-sources',
extractData: json => json.sources || [],
});
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
export const syncClocksCache = new DataCache({
endpoint: '/sync-clocks',
extractData: json => json.clocks || [],
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const filtersCache = new DataCache({
endpoint: '/filters',
extractData: json => json.filters || [],
});
filtersCache.subscribe(v => { _availableFilters = v; });
export const automationsCacheObj = new DataCache({
endpoint: '/automations',
extractData: json => json.automations || [],
});
automationsCacheObj.subscribe(v => { _automationsCache = v; });
export const colorStripSourcesCache = new DataCache({
endpoint: '/color-strip-sources',
extractData: json => json.sources || [],
});
export const csptCache = new DataCache({
endpoint: '/color-strip-processing-templates',
extractData: json => json.templates || [],
});
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
export const stripFiltersCache = new DataCache({
endpoint: '/strip-filters',
extractData: json => json.filters || [],
});
stripFiltersCache.subscribe(v => { _stripFilters = v; });
export const devicesCache = new DataCache({
endpoint: '/devices',
extractData: json => json.devices || [],
});
export const outputTargetsCache = new DataCache({
endpoint: '/output-targets',
extractData: json => json.targets || [],
});
export const patternTemplatesCache = new DataCache({
endpoint: '/pattern-templates',
extractData: json => json.templates || [],
});
export const scenePresetsCache = new DataCache({
endpoint: '/scene-presets',
extractData: json => json.presets || [],
});

View File

@@ -0,0 +1,320 @@
/**
* Shared mutable state — all global variables live here.
*
* ES module `export let` creates live bindings: importers always see
* the latest value. But importers cannot reassign, so every variable
* gets a setter function.
*/
import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate,
} from '../types.ts';
export let apiKey: string | null = null;
export function setApiKey(v: string | null) { apiKey = v; }
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
export let kcTestAutoRefresh: ReturnType<typeof setInterval> | null = null;
export function setKcTestAutoRefresh(v: ReturnType<typeof setInterval> | null) { kcTestAutoRefresh = v; }
export let kcTestTargetId: string | null = null;
export function setKcTestTargetId(v: string | null) { kcTestTargetId = v; }
export let kcTestWs: WebSocket | null = null;
export function setKcTestWs(v: WebSocket | null) { kcTestWs = v; }
export let kcTestFps = 3;
export function setKcTestFps(v: number) { kcTestFps = v; }
export let _cachedDisplays: Display[] | null = null;
export let _displayPickerCallback: ((index: number, display?: Display | null) => void) | null = null;
export function set_displayPickerCallback(v: ((index: number, display?: Display | null) => void) | null) { _displayPickerCallback = v; }
export let _displayPickerSelectedIndex: number | null = null;
export function set_displayPickerSelectedIndex(v: number | null) { _displayPickerSelectedIndex = v; }
// Calibration
export const calibrationTestState: Record<string, any> = {};
export const EDGE_TEST_COLORS: Record<string, number[]> = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Track logged errors to avoid console spam
export const loggedErrors = new Map<string, boolean>();
// Device brightness cache
export let _deviceBrightnessCache: Record<string, number> = {};
export function set_deviceBrightnessCache(v: Record<string, number>) { _deviceBrightnessCache = v; }
export function updateDeviceBrightness(deviceId: string, value: number) {
_deviceBrightnessCache = { ..._deviceBrightnessCache, [deviceId]: value };
}
// Discovery state
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
export let _discoveryCache: Record<string, any> = {};
export function set_discoveryCache(v: Record<string, any>) { _discoveryCache = v; }
// Streams / templates state
export let _cachedStreams: PictureSource[] = [];
export let _cachedPPTemplates: PostprocessingTemplate[] = [];
export let _cachedCaptureTemplates: CaptureTemplate[] = [];
export let _availableFilters: FilterDef[] = [];
export let availableEngines: EngineInfo[] = [];
export function setAvailableEngines(v: EngineInfo[]) { availableEngines = v; }
export let currentEditingTemplateId: string | null = null;
export function setCurrentEditingTemplateId(v: string | null) { currentEditingTemplateId = v; }
export let _streamNameManuallyEdited = false;
export function set_streamNameManuallyEdited(v: boolean) { _streamNameManuallyEdited = v; }
export let _streamModalPPTemplates: PostprocessingTemplate[] = [];
export function set_streamModalPPTemplates(v: PostprocessingTemplate[]) { _streamModalPPTemplates = v; }
export let _templateNameManuallyEdited = false;
export function set_templateNameManuallyEdited(v: boolean) { _templateNameManuallyEdited = v; }
// PP template state
export let _modalFilters: any[] = [];
export function set_modalFilters(v: any[]) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v: boolean) { _ppTemplateNameManuallyEdited = v; }
// CSPT (Color Strip Processing Template) state
export let _csptModalFilters: any[] = [];
export function set_csptModalFilters(v: any[]) { _csptModalFilters = v; }
export let _csptNameManuallyEdited = false;
export function set_csptNameManuallyEdited(v: boolean) { _csptNameManuallyEdited = v; }
export let _stripFilters: FilterDef[] = [];
export let _cachedCSPTemplates: ColorStripProcessingTemplate[] = [];
// Stream test state
export let currentTestingTemplate: CaptureTemplate | null = null;
export function setCurrentTestingTemplate(v: CaptureTemplate | null) { currentTestingTemplate = v; }
export let _currentTestStreamId: string | null = null;
export function set_currentTestStreamId(v: string | null) { _currentTestStreamId = v; }
export let _currentTestPPTemplateId: string | null = null;
export function set_currentTestPPTemplateId(v: string | null) { _currentTestPPTemplateId = v; }
export let _lastValidatedImageSource = '';
export function set_lastValidatedImageSource(v: string) { _lastValidatedImageSource = v; }
// Target editor state
export let _targetEditorDevices: Device[] = [];
export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; }
// KC editor state
export let _kcNameManuallyEdited = false;
export function set_kcNameManuallyEdited(v: boolean) { _kcNameManuallyEdited = v; }
// KC WebSockets
export const kcWebSockets: Record<string, WebSocket> = {};
// LED Preview WebSockets
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state
export let activeTutorial: any = null;
export function setActiveTutorial(v: any) { activeTutorial = v; }
// Confirm modal
export let confirmResolve: ((value: boolean) => void) | null = null;
export function setConfirmResolve(v: ((value: boolean) => void) | null) { confirmResolve = v; }
// Loading guards
export let _dashboardLoading = false;
export function set_dashboardLoading(v: boolean) { _dashboardLoading = v; }
export let _sourcesLoading = false;
export function set_sourcesLoading(v: boolean) { _sourcesLoading = v; }
export let _automationsLoading = false;
export function set_automationsLoading(v: boolean) { _automationsLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval';
const _POLL_DEFAULT = 2000;
export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY) as string, 10) || _POLL_DEFAULT;
export function setDashboardPollInterval(v: number) {
dashboardPollInterval = v;
localStorage.setItem(_POLL_KEY, String(v));
}
// Pattern template editor state
export let patternEditorRects: any[] = [];
export function setPatternEditorRects(v: any[]) { patternEditorRects = v; }
export let patternEditorSelectedIdx = -1;
export function setPatternEditorSelectedIdx(v: number) { patternEditorSelectedIdx = v; }
export let patternEditorBgImage: HTMLImageElement | null = null;
export function setPatternEditorBgImage(v: HTMLImageElement | null) { patternEditorBgImage = v; }
export let patternCanvasDragMode: string | null = null;
export function setPatternCanvasDragMode(v: string | null) { patternCanvasDragMode = v; }
export let patternCanvasDragStart: { x?: number; y?: number; mx?: number; my?: number } | null = null;
export function setPatternCanvasDragStart(v: { x?: number; y?: number; mx?: number; my?: number } | null) { patternCanvasDragStart = v; }
export let patternCanvasDragOrigRect: any = null;
export function setPatternCanvasDragOrigRect(v: any) { patternCanvasDragOrigRect = v; }
export let patternEditorHoveredIdx = -1;
export function setPatternEditorHoveredIdx(v: number) { patternEditorHoveredIdx = v; }
export let patternEditorHoverHit: string | null = null;
export function setPatternEditorHoverHit(v: string | null) { patternEditorHoverHit = v; }
export const PATTERN_RECT_COLORS = [
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
];
export const PATTERN_RECT_BORDERS = [
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
];
// Audio sources
export let _cachedAudioSources: AudioSource[] = [];
export let _cachedAudioTemplates: AudioTemplate[] = [];
export let availableAudioEngines: EngineInfo[] = [];
export function setAvailableAudioEngines(v: EngineInfo[]) { availableAudioEngines = v; }
export let currentEditingAudioTemplateId: string | null = null;
export function setCurrentEditingAudioTemplateId(v: string | null) { currentEditingAudioTemplateId = v; }
export let _audioTemplateNameManuallyEdited = false;
export function set_audioTemplateNameManuallyEdited(v: boolean) { _audioTemplateNameManuallyEdited = v; }
// Value sources
export let _cachedValueSources: ValueSource[] = [];
// Sync clocks
export let _cachedSyncClocks: SyncClock[] = [];
// Automations
export let _automationsCache: Automation[] | null = null;
// ─── DataCache instances ───────────────────────────────────────────
// Each cache syncs its data into the existing `export let` variable
// via a subscriber, preserving backward compatibility.
export const displaysCache = new DataCache<Display[] | null>({
endpoint: '/config/displays',
extractData: json => json.displays || [],
defaultValue: null,
});
displaysCache.subscribe(v => { _cachedDisplays = v; });
export const streamsCache = new DataCache<PictureSource[]>({
endpoint: '/picture-sources',
extractData: json => json.streams || [],
});
streamsCache.subscribe(v => { _cachedStreams = v; });
export const ppTemplatesCache = new DataCache<PostprocessingTemplate[]>({
endpoint: '/postprocessing-templates',
extractData: json => json.templates || [],
});
ppTemplatesCache.subscribe(v => { _cachedPPTemplates = v; });
export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
endpoint: '/capture-templates',
extractData: json => json.templates || [],
});
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
export const audioSourcesCache = new DataCache<AudioSource[]>({
endpoint: '/audio-sources',
extractData: json => json.sources || [],
});
audioSourcesCache.subscribe(v => { _cachedAudioSources = v; });
export const audioTemplatesCache = new DataCache<AudioTemplate[]>({
endpoint: '/audio-templates',
extractData: json => json.templates || [],
});
audioTemplatesCache.subscribe(v => { _cachedAudioTemplates = v; });
export const valueSourcesCache = new DataCache<ValueSource[]>({
endpoint: '/value-sources',
extractData: json => json.sources || [],
});
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
export const syncClocksCache = new DataCache<SyncClock[]>({
endpoint: '/sync-clocks',
extractData: json => json.clocks || [],
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const filtersCache = new DataCache<FilterDef[]>({
endpoint: '/filters',
extractData: json => json.filters || [],
});
filtersCache.subscribe(v => { _availableFilters = v; });
export const automationsCacheObj = new DataCache<Automation[]>({
endpoint: '/automations',
extractData: json => json.automations || [],
});
automationsCacheObj.subscribe(v => { _automationsCache = v; });
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
endpoint: '/color-strip-sources',
extractData: json => json.sources || [],
});
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
endpoint: '/color-strip-processing-templates',
extractData: json => json.templates || [],
});
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
export const stripFiltersCache = new DataCache<FilterDef[]>({
endpoint: '/strip-filters',
extractData: json => json.filters || [],
});
stripFiltersCache.subscribe(v => { _stripFilters = v; });
export const devicesCache = new DataCache<Device[]>({
endpoint: '/devices',
extractData: json => json.devices || [],
});
export const outputTargetsCache = new DataCache<OutputTarget[]>({
endpoint: '/output-targets',
extractData: json => json.targets || [],
});
export const patternTemplatesCache = new DataCache<PatternTemplate[]>({
endpoint: '/pattern-templates',
extractData: json => json.templates || [],
});
export const scenePresetsCache = new DataCache<ScenePreset[]>({
endpoint: '/scene-presets',
extractData: json => json.presets || [],
});

View File

@@ -11,8 +11,8 @@ const TAB_SVGS = {
graph: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg>`, graph: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg>`,
}; };
let _el = null; let _el: HTMLDivElement | null = null;
let _currentTab = null; let _currentTab: string | null = null;
function _ensureEl() { function _ensureEl() {
if (_el) return _el; if (_el) return _el;
@@ -63,7 +63,7 @@ export function initTabIndicator() {
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] }); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
// Set initial tab from current active button // Set initial tab from current active button
const active = document.querySelector('.tab-btn.active'); const active = document.querySelector('.tab-btn.active') as HTMLElement | null;
if (active) { if (active) {
updateTabIndicator(active.dataset.tab); updateTabIndicator(active.dataset.tab);
} }

View File

@@ -2,7 +2,7 @@
* TagInput reusable chip-based tag input with autocomplete. * TagInput reusable chip-based tag input with autocomplete.
* *
* Usage: * Usage:
* import { TagInput } from '../core/tag-input.js'; * import { TagInput } from '../core/tag-input.ts';
* *
* const tagInput = new TagInput(document.getElementById('my-container')); * const tagInput = new TagInput(document.getElementById('my-container'));
* tagInput.setValue(['bedroom', 'gaming']); * tagInput.setValue(['bedroom', 'gaming']);
@@ -13,13 +13,13 @@
* Tags are stored lowercase, trimmed, deduplicated. * Tags are stored lowercase, trimmed, deduplicated.
*/ */
import { fetchWithAuth } from './api.js'; import { fetchWithAuth } from './api.ts';
let _allTagsCache = null; let _allTagsCache: string[] | null = null;
let _allTagsFetchPromise = null; let _allTagsFetchPromise: Promise<string[]> | null = null;
/** Fetch all tags from API (cached). Call invalidateTagsCache() after mutations. */ /** Fetch all tags from API (cached). Call invalidateTagsCache() after mutations. */
export async function fetchAllTags() { export async function fetchAllTags(): Promise<string[]> {
if (_allTagsCache) return _allTagsCache; if (_allTagsCache) return _allTagsCache;
if (_allTagsFetchPromise) return _allTagsFetchPromise; if (_allTagsFetchPromise) return _allTagsFetchPromise;
_allTagsFetchPromise = fetchWithAuth('/tags') _allTagsFetchPromise = fetchWithAuth('/tags')
@@ -27,7 +27,7 @@ export async function fetchAllTags() {
.then(data => { .then(data => {
_allTagsCache = data.tags || []; _allTagsCache = data.tags || [];
_allTagsFetchPromise = null; _allTagsFetchPromise = null;
return _allTagsCache; return _allTagsCache!;
}) })
.catch(() => { .catch(() => {
_allTagsFetchPromise = null; _allTagsFetchPromise = null;
@@ -46,24 +46,33 @@ export function invalidateTagsCache() {
* @param {string[]} tags * @param {string[]} tags
* @returns {string} HTML string * @returns {string} HTML string
*/ */
export function renderTagChips(tags) { export function renderTagChips(tags: string[]): string {
if (!tags || !tags.length) return ''; if (!tags || !tags.length) return '';
return `<div class="card-tags">${tags.map(tag => return `<div class="card-tags">${tags.map(tag =>
`<span class="card-tag">${_escapeHtml(tag)}</span>` `<span class="card-tag">${_escapeHtml(tag)}</span>`
).join('')}</div>`; ).join('')}</div>`;
} }
function _escapeHtml(str) { function _escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
export class TagInput { export class TagInput {
_container: HTMLElement;
_tags: string[];
_placeholder: string;
_dropdownVisible: boolean;
_selectedIdx: number;
_chipsEl!: HTMLElement;
_inputEl!: HTMLInputElement;
_dropdownEl!: HTMLElement;
/** /**
* @param {HTMLElement} container Element to render the tag input into * @param {HTMLElement} container Element to render the tag input into
* @param {object} [opts] * @param {object} [opts]
* @param {string} [opts.placeholder] Placeholder text for input * @param {string} [opts.placeholder] Placeholder text for input
*/ */
constructor(container, opts = {}) { constructor(container: HTMLElement, opts: any = {}) {
this._container = container; this._container = container;
this._tags = []; this._tags = [];
this._placeholder = opts.placeholder || 'Add tag...'; this._placeholder = opts.placeholder || 'Add tag...';
@@ -74,11 +83,11 @@ export class TagInput {
this._bindEvents(); this._bindEvents();
} }
getValue() { getValue(): string[] {
return [...this._tags]; return [...this._tags];
} }
setValue(tags) { setValue(tags: string[]) {
this._tags = (tags || []).map(t => t.toLowerCase().trim()).filter(Boolean); this._tags = (tags || []).map(t => t.toLowerCase().trim()).filter(Boolean);
this._tags = [...new Set(this._tags)]; this._tags = [...new Set(this._tags)];
this._renderChips(); this._renderChips();
@@ -99,9 +108,9 @@ export class TagInput {
<div class="tag-input-dropdown"></div> <div class="tag-input-dropdown"></div>
</div> </div>
`; `;
this._chipsEl = this._container.querySelector('.tag-input-chips'); this._chipsEl = this._container.querySelector('.tag-input-chips') as HTMLElement;
this._inputEl = this._container.querySelector('.tag-input-field'); this._inputEl = this._container.querySelector('.tag-input-field') as HTMLInputElement;
this._dropdownEl = this._container.querySelector('.tag-input-dropdown'); this._dropdownEl = this._container.querySelector('.tag-input-dropdown') as HTMLElement;
} }
_renderChips() { _renderChips() {
@@ -113,9 +122,9 @@ export class TagInput {
_bindEvents() { _bindEvents() {
// Chip remove buttons // Chip remove buttons
this._chipsEl.addEventListener('click', (e) => { this._chipsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.tag-chip-remove'); const btn = (e.target as HTMLElement).closest('.tag-chip-remove') as HTMLElement | null;
if (!btn) return; if (!btn) return;
const idx = parseInt(btn.dataset.idx, 10); const idx = parseInt((btn as HTMLElement).dataset.idx!, 10);
this._tags.splice(idx, 1); this._tags.splice(idx, 1);
this._renderChips(); this._renderChips();
}); });
@@ -128,7 +137,7 @@ export class TagInput {
e.preventDefault(); e.preventDefault();
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item'); const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
if (items[this._selectedIdx]) { if (items[this._selectedIdx]) {
this._addTag(items[this._selectedIdx].dataset.tag); this._addTag((items[this._selectedIdx] as HTMLElement).dataset.tag!);
} }
} else if (this._inputEl.value.trim()) { } else if (this._inputEl.value.trim()) {
e.preventDefault(); e.preventDefault();
@@ -166,12 +175,12 @@ export class TagInput {
// Dropdown click // Dropdown click
this._dropdownEl.addEventListener('mousedown', (e) => { this._dropdownEl.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent blur e.preventDefault(); // prevent blur
const item = e.target.closest('.tag-dropdown-item'); const item = (e.target as HTMLElement).closest('.tag-dropdown-item') as HTMLElement | null;
if (item) this._addTag(item.dataset.tag); if (item) this._addTag(item.dataset.tag!);
}); });
} }
_addTag(raw) { _addTag(raw: string) {
const tag = raw.toLowerCase().trim().replace(/,/g, ''); const tag = raw.toLowerCase().trim().replace(/,/g, '');
if (!tag || this._tags.includes(tag)) { if (!tag || this._tags.includes(tag)) {
this._inputEl.value = ''; this._inputEl.value = '';
@@ -214,7 +223,7 @@ export class TagInput {
this._selectedIdx = -1; this._selectedIdx = -1;
} }
_moveSelection(delta) { _moveSelection(delta: number) {
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item'); const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
if (!items.length) return; if (!items.length) return;
items[this._selectedIdx]?.classList.remove('tag-dropdown-active'); items[this._selectedIdx]?.classList.remove('tag-dropdown-active');

View File

@@ -1,299 +0,0 @@
/**
* TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
* Replaces flat sub-tab bars with a collapsible tree that groups related items.
*
* Config format (supports arbitrary nesting):
* [
* { key, titleKey, icon?, children: [
* { key, titleKey, icon?, children: [...] }, // nested group
* { key, titleKey, icon?, count } // leaf
* ] },
* { key, titleKey, icon?, count } // standalone leaf (no children)
* ]
*/
import { t } from './i18n.js';
const STORAGE_KEY = 'tree_nav_collapsed';
function _getCollapsedMap() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
catch { return {}; }
}
function _saveCollapsed(key, collapsed) {
const map = _getCollapsedMap();
if (collapsed) map[key] = true;
else delete map[key];
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
/** Recursively sum leaf counts in a tree node. */
function _deepCount(node) {
if (!node.children) return node.count || 0;
return node.children.reduce((sum, c) => sum + _deepCount(c), 0);
}
export class TreeNav {
/**
* @param {string} containerId - ID of the nav element to render into
* @param {object} opts
* @param {function} opts.onSelect - callback(leafKey, leafData) when a leaf is clicked
*/
constructor(containerId, { onSelect }) {
this.containerId = containerId;
this.onSelect = onSelect;
this._items = [];
this._leafMap = new Map(); // key → leaf config
this._activeLeaf = null;
this._extraHtml = '';
this._observerSuppressed = false;
}
/** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */
suppressObserver(ms = 800) {
this._observerSuppressed = true;
clearTimeout(this._suppressTimer);
this._suppressTimer = setTimeout(() => { this._observerSuppressed = false; }, ms);
}
/**
* Full re-render of the tree.
* @param {Array} items - tree structure (groups and/or standalone leaves)
* @param {string} activeLeafKey
*/
update(items, activeLeafKey) {
this._items = items;
this._activeLeaf = activeLeafKey;
this._buildLeafMap();
this._render();
}
/** Update only the counts without full re-render. */
updateCounts(countMap) {
const container = document.getElementById(this.containerId);
if (!container) return;
for (const [key, count] of Object.entries(countMap)) {
const el = container.querySelector(`[data-tree-leaf="${key}"] .tree-count`);
if (el) el.textContent = count;
// Also update in-memory
const leaf = this._leafMap.get(key);
if (leaf) leaf.count = count;
}
// Update group counts (bottom-up: deepest first)
const groups = [...container.querySelectorAll('[data-tree-group]')];
groups.reverse();
for (const groupEl of groups) {
let total = 0;
// Sum direct leaf children
for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-leaf .tree-count')) {
total += parseInt(cnt.textContent, 10) || 0;
}
// Sum nested sub-group counts
for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-group > .tree-group-header > .tree-group-count')) {
total += parseInt(cnt.textContent, 10) || 0;
}
const groupCount = groupEl.querySelector(':scope > .tree-group-header > .tree-group-count');
if (groupCount) groupCount.textContent = total;
}
}
/** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
setExtraHtml(html) {
this._extraHtml = html;
}
/** Highlight a specific leaf. */
setActive(leafKey) {
this._activeLeaf = leafKey;
const container = document.getElementById(this.containerId);
if (!container) return;
container.querySelectorAll('.tree-leaf').forEach(el =>
el.classList.toggle('active', el.dataset.treeLeaf === leafKey)
);
// Also highlight standalone leaves
container.querySelectorAll('.tree-standalone').forEach(el =>
el.classList.toggle('active', el.dataset.treeLeaf === leafKey)
);
}
/** Get leaf data for a key. */
getLeaf(key) {
return this._leafMap.get(key);
}
/** Find the first leaf key whose subTab matches. */
getLeafForSubTab(subTab) {
for (const [key, leaf] of this._leafMap) {
if ((leaf.subTab || key) === subTab) return key;
}
return null;
}
_buildLeafMap() {
this._leafMap.clear();
this._collectLeaves(this._items);
}
_collectLeaves(items) {
for (const item of items) {
if (item.children) {
this._collectLeaves(item.children);
} else {
this._leafMap.set(item.key, item);
}
}
}
_render() {
const container = document.getElementById(this.containerId);
if (!container) return;
const collapsed = _getCollapsedMap();
const html = this._items.map(item => {
if (item.children) {
return this._renderGroup(item, collapsed, 0);
}
return this._renderStandalone(item);
}).join('');
container.innerHTML = html +
(this._extraHtml ? `<div class="tree-extra">${this._extraHtml}</div>` : '');
this._bindEvents(container);
}
_renderGroup(group, collapsed, depth) {
const isCollapsed = !!collapsed[group.key];
const groupCount = _deepCount(group);
const childrenHtml = group.children.map(child => {
if (child.children) {
return this._renderGroup(child, collapsed, depth + 1);
}
return `
<div class="tree-leaf${child.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${child.key}">
${child.icon ? `<span class="tree-node-icon">${child.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${child.titleKey}">${t(child.titleKey)}</span>
<span class="tree-count">${child.count ?? 0}</span>
</div>`;
}).join('');
return `
<div class="tree-group${depth > 0 ? ' tree-group-nested' : ''}" data-tree-group="${group.key}">
<div class="tree-group-header" data-tree-group-toggle="${group.key}">
<span class="tree-chevron${isCollapsed ? '' : ' open'}">&#9654;</span>
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${group.titleKey}">${t(group.titleKey)}</span>
<span class="tree-group-count">${groupCount}</span>
</div>
<div class="tree-children${isCollapsed ? ' collapsed' : ''}">
${childrenHtml}
</div>
</div>`;
}
_renderStandalone(leaf) {
return `
<div class="tree-standalone${leaf.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${leaf.key}">
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
<span class="tree-count">${leaf.count ?? 0}</span>
</div>`;
}
/**
* Start observing card-section elements so the active tree leaf
* follows whichever section is currently visible on screen.
* @param {string} contentId - ID of the scrollable content container
* @param {Object<string,string>} [sectionMap] - optional { data-card-section → leafKey } override
*/
observeSections(contentId, sectionMap) {
this.stopObserving();
const content = document.getElementById(contentId);
if (!content) return;
// Build sectionKey → leafKey mapping
const sectionToLeaf = new Map();
if (sectionMap) {
for (const [sk, lk] of Object.entries(sectionMap)) sectionToLeaf.set(sk, lk);
} else {
for (const [key, leaf] of this._leafMap) {
sectionToLeaf.set(leaf.sectionKey || key, key);
}
}
const stickyTop = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--sticky-top')) || 90;
// Track which sections are currently intersecting
const visible = new Set();
this._observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target);
else visible.delete(entry.target);
}
if (this._observerSuppressed) return;
// Read fresh rects to find the topmost visible section
let bestEl = null;
let bestTop = Infinity;
for (const el of visible) {
const top = el.getBoundingClientRect().top;
if (top < bestTop) { bestTop = top; bestEl = el; }
}
if (bestEl) {
const sectionKey = bestEl.dataset.cardSection;
const leafKey = sectionToLeaf.get(sectionKey);
if (leafKey && leafKey !== this._activeLeaf) {
this._activeLeaf = leafKey;
this.setActive(leafKey);
}
}
}, {
rootMargin: `-${stickyTop}px 0px -40% 0px`,
threshold: 0
});
content.querySelectorAll('[data-card-section]').forEach(section => {
if (sectionToLeaf.has(section.dataset.cardSection)) {
this._observer.observe(section);
}
});
}
/** Stop the scroll-spy observer. */
stopObserving() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
_bindEvents(container) {
// Group header toggle
container.querySelectorAll('.tree-group-header').forEach(header => {
header.addEventListener('click', () => {
const key = header.dataset.treeGroupToggle;
const children = header.nextElementSibling;
if (!children) return;
const isNowCollapsed = children.classList.toggle('collapsed');
const chevron = header.querySelector('.tree-chevron');
if (chevron) chevron.classList.toggle('open', !isNowCollapsed);
_saveCollapsed(key, isNowCollapsed);
});
});
// Leaf click (both grouped and standalone)
container.querySelectorAll('[data-tree-leaf]').forEach(el => {
el.addEventListener('click', (e) => {
// Don't trigger on group header clicks
if (el.closest('.tree-group-header')) return;
const key = el.dataset.treeLeaf;
this.setActive(key);
this.suppressObserver();
if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
});
});
}
}

View File

@@ -0,0 +1,353 @@
/**
* TreeNav — dropdown navigation for Targets and Sources tabs.
* Shows a compact trigger bar with the active leaf; click to open a
* grouped dropdown menu for switching between sections.
*
* Config format (supports arbitrary nesting):
* [
* { key, titleKey, icon?, children: [
* { key, titleKey, icon?, children: [...] }, // nested group
* { key, titleKey, icon?, count } // leaf
* ] },
* { key, titleKey, icon?, count } // standalone leaf (no children)
* ]
*/
import { t } from './i18n.ts';
/** Recursively sum leaf counts in a tree node. */
function _deepCount(node: any): number {
if (!node.children) return node.count || 0;
return node.children.reduce((sum: number, c: any) => sum + _deepCount(c), 0);
}
export class TreeNav {
containerId: string;
onSelect: any;
_items: any[];
_leafMap: Map<string, any>;
_activeLeaf: string | null;
_extraHtml: string;
_observerSuppressed: boolean;
_open: boolean;
_outsideHandler: any;
_escHandler: any;
_suppressTimer: any;
_observer: IntersectionObserver | null;
/**
* @param containerId - ID of the nav element to render into
* @param opts
* @param opts.onSelect - callback(leafKey, leafData) when a leaf is clicked
*/
constructor(containerId: string, { onSelect }: { onSelect: any }) {
this.containerId = containerId;
this.onSelect = onSelect;
this._items = [];
this._leafMap = new Map(); // key → leaf config
this._activeLeaf = null;
this._extraHtml = '';
this._observerSuppressed = false;
this._open = false;
this._outsideHandler = null;
this._escHandler = null;
this._suppressTimer = null;
this._observer = null;
}
/** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */
suppressObserver(ms = 800) {
this._observerSuppressed = true;
clearTimeout(this._suppressTimer);
this._suppressTimer = setTimeout(() => { this._observerSuppressed = false; }, ms);
}
/**
* Full re-render of the nav.
* @param items - tree structure (groups and/or standalone leaves)
* @param activeLeafKey
*/
update(items: any[], activeLeafKey: string) {
this._items = items;
this._activeLeaf = activeLeafKey;
this._buildLeafMap();
this._render();
}
/** Update only the counts without full re-render. */
updateCounts(countMap: Record<string, any>) {
const container = document.getElementById(this.containerId);
if (!container) return;
for (const [key, count] of Object.entries(countMap)) {
// Update leaves in dropdown panel
const els = container.querySelectorAll(`[data-tree-leaf="${key}"] .tree-count`);
els.forEach(el => { el.textContent = String(count); });
// Update in-memory
const leaf = this._leafMap.get(key);
if (leaf) leaf.count = count;
}
// Update group counts (bottom-up: deepest first)
const groups = Array.from(container.querySelectorAll('[data-tree-group]')) as HTMLElement[];
groups.reverse();
for (const groupEl of groups) {
let total = 0;
for (const cnt of Array.from(groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-leaf] .tree-count'))) {
total += parseInt(cnt.textContent || '0', 10) || 0;
}
for (const cnt of Array.from(groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-group] > .tree-dd-group-header .tree-dd-group-count'))) {
total += parseInt(cnt.textContent || '0', 10) || 0;
}
const gc = groupEl.querySelector(':scope > .tree-dd-group-header .tree-dd-group-count');
if (gc) gc.textContent = String(total);
}
// Update trigger display if active leaf count changed
if (this._activeLeaf && countMap[this._activeLeaf] !== undefined) {
this._updateTrigger();
}
}
/** Set extra HTML appended in the trigger bar (expand/collapse buttons, etc.) */
setExtraHtml(html: string) {
this._extraHtml = html;
}
/** Highlight a specific leaf. */
setActive(leafKey: string) {
this._activeLeaf = leafKey;
const container = document.getElementById(this.containerId);
if (!container) return;
container.querySelectorAll('[data-tree-leaf]').forEach(el =>
(el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.treeLeaf === leafKey)
);
this._updateTrigger();
}
/** Get leaf data for a key. */
getLeaf(key: string) {
return this._leafMap.get(key);
}
/** Find the first leaf key whose subTab matches. */
getLeafForSubTab(subTab: string) {
for (const [key, leaf] of Array.from(this._leafMap)) {
if ((leaf.subTab || key) === subTab) return key;
}
return null;
}
// ── internal ──
_buildLeafMap() {
this._leafMap.clear();
this._collectLeaves(this._items);
}
_collectLeaves(items: any[]) {
for (const item of items) {
if (item.children) {
this._collectLeaves(item.children);
} else {
this._leafMap.set(item.key, item);
}
}
}
_render() {
const container = document.getElementById(this.containerId);
if (!container) return;
const leaf = this._leafMap.get(this._activeLeaf!);
const triggerIcon = leaf?.icon || '';
const triggerTitle = leaf ? t(leaf.titleKey) : '';
const triggerCount = leaf?.count ?? 0;
const panelHtml = this._items.map(item =>
item.children ? this._renderGroup(item, 0) : this._renderLeaf(item)
).join('');
container.innerHTML = `
<div class="tree-dd-trigger" data-tree-trigger>
<span class="tree-dd-trigger-icon">${triggerIcon}</span>
<span class="tree-dd-trigger-title">${triggerTitle}</span>
<span class="tree-dd-trigger-count tree-count">${triggerCount}</span>
<span class="tree-dd-chevron">&#9662;</span>
${this._extraHtml ? `<span class="tree-dd-extra">${this._extraHtml}</span>` : ''}
</div>
<div class="tree-dd-panel" data-tree-panel>
${panelHtml}
</div>`;
this._bindEvents(container);
}
_renderGroup(group: any, depth: number) {
const groupCount = _deepCount(group);
const childrenHtml = group.children.map((child: any) =>
child.children ? this._renderGroup(child, depth + 1) : this._renderLeaf(child)
).join('');
return `
<div class="tree-dd-group" data-tree-group="${group.key}">
<div class="tree-dd-group-header tree-dd-depth-${depth}">
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
<span class="tree-dd-group-title" data-i18n="${group.titleKey}">${t(group.titleKey)}</span>
<span class="tree-dd-group-count">${groupCount}</span>
</div>
<div class="tree-dd-children">
${childrenHtml}
</div>
</div>`;
}
_renderLeaf(leaf: any) {
const isActive = leaf.key === this._activeLeaf;
return `
<div class="tree-dd-leaf${isActive ? ' active' : ''}" data-tree-leaf="${leaf.key}">
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
<span class="tree-count">${leaf.count ?? 0}</span>
</div>`;
}
_updateTrigger() {
const container = document.getElementById(this.containerId);
if (!container) return;
const leaf = this._leafMap.get(this._activeLeaf!);
if (!leaf) return;
const icon = container.querySelector('.tree-dd-trigger-icon');
const title = container.querySelector('.tree-dd-trigger-title');
const count = container.querySelector('.tree-dd-trigger-count');
if (icon) icon.innerHTML = leaf.icon || '';
if (title) title.textContent = t(leaf.titleKey);
if (count) count.textContent = String(leaf.count ?? 0);
}
_openDropdown(container: HTMLElement) {
if (this._open) return;
this._open = true;
const panel = container.querySelector('[data-tree-panel]') as HTMLElement | null;
const trigger = container.querySelector('[data-tree-trigger]') as HTMLElement | null;
if (panel) panel.classList.add('open');
if (trigger) trigger.classList.add('open');
this._outsideHandler = (e: Event) => {
const inTrigger = trigger && trigger.contains(e.target as Node);
const inPanel = panel && panel.contains(e.target as Node);
if (!inTrigger && !inPanel) this._closeDropdown(container);
};
this._escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') this._closeDropdown(container);
};
// Use timeout so the current pointerdown doesn't immediately trigger close
setTimeout(() => {
window.addEventListener('mousedown', this._outsideHandler, true);
window.addEventListener('pointerdown', this._outsideHandler, true);
window.addEventListener('keydown', this._escHandler, true);
}, 0);
}
_closeDropdown(container: HTMLElement) {
if (!this._open) return;
this._open = false;
const panel = container.querySelector('[data-tree-panel]') as HTMLElement | null;
const trigger = container.querySelector('[data-tree-trigger]') as HTMLElement | null;
if (panel) panel.classList.remove('open');
if (trigger) trigger.classList.remove('open');
window.removeEventListener('mousedown', this._outsideHandler, true);
window.removeEventListener('pointerdown', this._outsideHandler, true);
window.removeEventListener('keydown', this._escHandler, true);
}
_bindEvents(container: HTMLElement) {
// Trigger click — toggle dropdown
const trigger = container.querySelector('[data-tree-trigger]');
if (trigger) {
trigger.addEventListener('pointerdown', (e) => {
// Don't toggle when clicking extra buttons (expand/collapse/help)
if ((e.target as HTMLElement).closest('.tree-dd-extra')) return;
e.preventDefault();
if (this._open) this._closeDropdown(container);
else this._openDropdown(container);
});
}
// Leaf click — select and close
container.querySelectorAll('[data-tree-leaf]').forEach(el => {
el.addEventListener('click', () => {
const key = (el as HTMLElement).dataset.treeLeaf;
this.setActive(key!);
this._closeDropdown(container);
this.suppressObserver();
if (this.onSelect) this.onSelect(key, this._leafMap.get(key!));
});
});
}
/**
* Start observing card-section elements so the active tree leaf
* follows whichever section is currently visible on screen.
* @param contentId - ID of the scrollable content container
* @param sectionMap - optional { data-card-section → leafKey } override
*/
observeSections(contentId: string, sectionMap?: Record<string, string>) {
this.stopObserving();
const content = document.getElementById(contentId);
if (!content) return;
// Build sectionKey → leafKey mapping
const sectionToLeaf = new Map<string, string>();
if (sectionMap) {
for (const [sk, lk] of Object.entries(sectionMap)) sectionToLeaf.set(sk, lk);
} else {
for (const [key, leaf] of Array.from(this._leafMap)) {
sectionToLeaf.set(leaf.sectionKey || key, key);
}
}
const stickyTop = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--sticky-top')) || 90;
// Track which sections are currently intersecting
const visible = new Set<Element>();
this._observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target);
else visible.delete(entry.target);
}
if (this._observerSuppressed) return;
// Read fresh rects to find the topmost visible section
let bestEl: Element | null = null;
let bestTop = Infinity;
for (const el of Array.from(visible)) {
const top = el.getBoundingClientRect().top;
if (top < bestTop) { bestTop = top; bestEl = el; }
}
if (bestEl) {
const sectionKey = (bestEl as HTMLElement).dataset.cardSection;
const leafKey = sectionToLeaf.get(sectionKey!);
if (leafKey && leafKey !== this._activeLeaf) {
this._activeLeaf = leafKey;
this.setActive(leafKey);
}
}
}, {
rootMargin: `-${stickyTop}px 0px -40% 0px`,
threshold: 0
});
content.querySelectorAll('[data-card-section]').forEach(section => {
if (sectionToLeaf.has((section as HTMLElement).dataset.cardSection!)) {
this._observer!.observe(section);
}
});
}
/** Stop the scroll-spy observer. */
stopObserving() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
}

View File

@@ -2,8 +2,8 @@
* UI utilities modal helpers, lightbox, toast, confirm. * UI utilities modal helpers, lightbox, toast, confirm.
*/ */
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.js'; import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts';
import { t } from './i18n.js'; import { t } from './i18n.ts';
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */ /** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
export function isTouchDevice() { export function isTouchDevice() {
@@ -11,12 +11,12 @@ export function isTouchDevice() {
} }
/** Focus element only on non-touch devices (avoids virtual keyboard popup on mobile) */ /** Focus element only on non-touch devices (avoids virtual keyboard popup on mobile) */
export function desktopFocus(el) { export function desktopFocus(el: HTMLElement | null) {
if (el && !isTouchDevice()) el.focus(); if (el && !isTouchDevice()) el.focus();
} }
export function toggleHint(btn) { export function toggleHint(btn: HTMLElement) {
const hint = btn.closest('.label-row').nextElementSibling; const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null;
if (hint && hint.classList.contains('input-hint')) { if (hint && hint.classList.contains('input-hint')) {
const visible = hint.style.display !== 'none'; const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block'; hint.style.display = visible ? 'none' : 'block';
@@ -27,10 +27,10 @@ export function toggleHint(btn) {
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function trapFocus(modal) { export function trapFocus(modal: any) {
modal._trapHandler = (e) => { modal._trapHandler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return; if (e.key !== 'Tab') return;
const focusable = [...modal.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null); const focusable = [...modal.querySelectorAll(FOCUSABLE)].filter((el: HTMLElement) => el.offsetParent !== null);
if (!focusable.length) return; if (!focusable.length) return;
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1];
@@ -43,14 +43,14 @@ export function trapFocus(modal) {
modal.addEventListener('keydown', modal._trapHandler); modal.addEventListener('keydown', modal._trapHandler);
} }
export function releaseFocus(modal) { export function releaseFocus(modal: any) {
if (modal._trapHandler) { if (modal._trapHandler) {
modal.removeEventListener('keydown', modal._trapHandler); modal.removeEventListener('keydown', modal._trapHandler);
modal._trapHandler = null; modal._trapHandler = null;
} }
} }
export function setupBackdropClose(modal, closeFn) { export function setupBackdropClose(modal: any, closeFn: () => void) {
if (modal._backdropCloseSetup) { if (modal._backdropCloseSetup) {
modal._backdropCloseFn = closeFn; modal._backdropCloseFn = closeFn;
return; return;
@@ -67,13 +67,10 @@ export function setupBackdropClose(modal, closeFn) {
} }
let _lockCount = 0; let _lockCount = 0;
let _savedScrollY = 0;
export function lockBody() { export function lockBody() {
if (_lockCount === 0) { if (_lockCount === 0) {
_savedScrollY = window.scrollY; document.documentElement.classList.add('modal-open');
document.body.style.top = `-${_savedScrollY}px`;
document.body.classList.add('modal-open');
} }
_lockCount++; _lockCount++;
} }
@@ -82,16 +79,14 @@ export function unlockBody() {
if (_lockCount <= 0) return; if (_lockCount <= 0) return;
_lockCount--; _lockCount--;
if (_lockCount === 0) { if (_lockCount === 0) {
document.body.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open');
document.body.style.top = '';
window.scrollTo(0, _savedScrollY);
} }
} }
export function openLightbox(imageSrc, statsHtml) { export function openLightbox(imageSrc: string, statsHtml?: string) {
const lightbox = document.getElementById('image-lightbox'); const lightbox = document.getElementById('image-lightbox')!;
const img = document.getElementById('lightbox-image'); const img = document.getElementById('lightbox-image') as HTMLImageElement;
const statsEl = document.getElementById('lightbox-stats'); const statsEl = document.getElementById('lightbox-stats')!;
img.src = imageSrc; img.src = imageSrc;
if (statsHtml) { if (statsHtml) {
statsEl.innerHTML = statsHtml; statsEl.innerHTML = statsHtml;
@@ -103,18 +98,18 @@ export function openLightbox(imageSrc, statsHtml) {
lockBody(); lockBody();
} }
export function closeLightbox(event) { export function closeLightbox(event?: Event) {
if (event && event.target && event.target.closest('.lightbox-content')) return; if (event && event.target && (event.target as HTMLElement).closest('.lightbox-content')) return;
// Stop KC test WS if running // Stop KC test WS if running
stopKCTestAutoRefresh(); stopKCTestAutoRefresh();
const lightbox = document.getElementById('image-lightbox'); const lightbox = document.getElementById('image-lightbox')!;
lightbox.classList.remove('active'); lightbox.classList.remove('active');
const img = document.getElementById('lightbox-image'); const img = document.getElementById('lightbox-image') as HTMLImageElement;
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = ''; img.src = '';
img.style.display = ''; img.style.display = '';
document.getElementById('lightbox-stats').style.display = 'none'; document.getElementById('lightbox-stats')!.style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner'); const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none'; if (spinner) spinner.style.display = 'none';
unlockBody(); unlockBody();
} }
@@ -131,8 +126,8 @@ export function stopKCTestAutoRefresh() {
setKcTestTargetId(null); setKcTestTargetId(null);
} }
export function showToast(message, type = 'info') { export function showToast(message: string, type = 'info') {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast')!;
toast.textContent = message; toast.textContent = message;
toast.className = `toast ${type} show`; toast.className = `toast ${type} show`;
setTimeout(() => { setTimeout(() => {
@@ -140,15 +135,52 @@ export function showToast(message, type = 'info') {
}, 3000); }, 3000);
} }
export function showConfirm(message, title = null) { let _undoTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Show a toast with an Undo button. Executes `action` after `delay` ms
* unless the user clicks Undo (which calls `undoFn`).
*/
export function showUndoToast(message: string, action: () => void, undoFn: () => void, delay = 5000) {
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
const toast = document.getElementById('toast')!;
toast.className = 'toast info show';
toast.style.setProperty('--toast-duration', `${delay}ms`);
toast.innerHTML = `<div class="toast-with-action">
<span class="toast-message">${message}</span>
<button class="toast-undo-btn">${t('common.undo')}</button>
</div>
<div class="toast-timer"></div>`;
let cancelled = false;
const undoBtn = toast.querySelector('.toast-undo-btn')!;
undoBtn.addEventListener('click', () => {
cancelled = true;
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
undoFn();
toast.className = 'toast';
toast.innerHTML = '';
}, { once: true });
_undoTimer = setTimeout(() => {
_undoTimer = null;
if (!cancelled) action();
toast.className = 'toast';
toast.innerHTML = '';
}, delay);
}
export function showConfirm(message: string, title: string | null = null) {
return new Promise((resolve) => { return new Promise((resolve) => {
setConfirmResolve(resolve); setConfirmResolve(resolve);
const modal = document.getElementById('confirm-modal'); const modal = document.getElementById('confirm-modal')!;
const titleEl = document.getElementById('confirm-modal-title'); const titleEl = document.getElementById('confirm-modal-title')!;
const messageEl = document.getElementById('confirm-message'); const messageEl = document.getElementById('confirm-message')!;
const yesBtn = document.getElementById('confirm-yes-btn'); const yesBtn = document.getElementById('confirm-yes-btn')!;
const noBtn = document.getElementById('confirm-no-btn'); const noBtn = document.getElementById('confirm-no-btn')!;
titleEl.textContent = title || t('confirm.title'); titleEl.textContent = title || t('confirm.title');
messageEl.textContent = message; messageEl.textContent = message;
@@ -161,8 +193,8 @@ export function showConfirm(message, title = null) {
}); });
} }
export function closeConfirmModal(result) { export function closeConfirmModal(result: boolean) {
const modal = document.getElementById('confirm-modal'); const modal = document.getElementById('confirm-modal')!;
releaseFocus(modal); releaseFocus(modal);
modal.style.display = 'none'; modal.style.display = 'none';
unlockBody(); unlockBody();
@@ -173,7 +205,7 @@ export function closeConfirmModal(result) {
} }
} }
export async function openFullImageLightbox(imageSource) { export async function openFullImageLightbox(imageSource: string) {
try { try {
const { API_BASE, getHeaders } = await import('./api.js'); const { API_BASE, getHeaders } = await import('./api.js');
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, { const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
@@ -189,7 +221,7 @@ export async function openFullImageLightbox(imageSource) {
} }
// Overlay spinner (used by capture/stream tests) // Overlay spinner (used by capture/stream tests)
export function showOverlaySpinner(text, duration = 0) { export function showOverlaySpinner(text: string, duration = 0) {
const existing = document.getElementById('overlay-spinner'); const existing = document.getElementById('overlay-spinner');
if (existing) { if (existing) {
if (window.overlaySpinnerTimer) { if (window.overlaySpinnerTimer) {
@@ -215,7 +247,7 @@ export function showOverlaySpinner(text, duration = 0) {
overlay.appendChild(closeBtn); overlay.appendChild(closeBtn);
// ESC key handler // ESC key handler
function onEsc(e) { function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') hideOverlaySpinner(); if (e.key === 'Escape') hideOverlaySpinner();
} }
window._overlayEscHandler = onEsc; window._overlayEscHandler = onEsc;
@@ -236,15 +268,15 @@ export function showOverlaySpinner(text, duration = 0) {
bgCircle.setAttribute('class', 'progress-ring-bg'); bgCircle.setAttribute('class', 'progress-ring-bg');
bgCircle.setAttribute('cx', '60'); bgCircle.setAttribute('cx', '60');
bgCircle.setAttribute('cy', '60'); bgCircle.setAttribute('cy', '60');
bgCircle.setAttribute('r', radius); bgCircle.setAttribute('r', String(radius));
const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
progressCircle.setAttribute('class', 'progress-ring-circle'); progressCircle.setAttribute('class', 'progress-ring-circle');
progressCircle.setAttribute('cx', '60'); progressCircle.setAttribute('cx', '60');
progressCircle.setAttribute('cy', '60'); progressCircle.setAttribute('cy', '60');
progressCircle.setAttribute('r', radius); progressCircle.setAttribute('r', String(radius));
progressCircle.style.strokeDasharray = circumference; progressCircle.style.strokeDasharray = String(circumference);
progressCircle.style.strokeDashoffset = circumference; progressCircle.style.strokeDashoffset = String(circumference);
svg.appendChild(bgCircle); svg.appendChild(bgCircle);
svg.appendChild(progressCircle); svg.appendChild(progressCircle);
@@ -288,7 +320,7 @@ export function showOverlaySpinner(text, duration = 0) {
const progress = Math.min(elapsed / duration, 1); const progress = Math.min(elapsed / duration, 1);
const percentage = Math.round(progress * 100); const percentage = Math.round(progress * 100);
const offset = circumference - (progress * circumference); const offset = circumference - (progress * circumference);
progressCircle.style.strokeDashoffset = offset; progressCircle.style.strokeDashoffset = String(offset);
progressPercentage.textContent = `${percentage}%`; progressPercentage.textContent = `${percentage}%`;
if (progress >= 1) { if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer); clearInterval(window.overlaySpinnerTimer);
@@ -319,8 +351,8 @@ export function hideOverlaySpinner() {
* Update the overlay spinner with a live preview thumbnail and stats. * Update the overlay spinner with a live preview thumbnail and stats.
* Call this while the spinner is open to show intermediate test frames. * Call this while the spinner is open to show intermediate test frames.
*/ */
export function updateOverlayPreview(thumbnailSrc, stats) { export function updateOverlayPreview(thumbnailSrc: string, stats: any) {
const img = document.getElementById('overlay-preview-img'); const img = document.getElementById('overlay-preview-img') as HTMLImageElement | null;
const statsEl = document.getElementById('overlay-preview-stats'); const statsEl = document.getElementById('overlay-preview-stats');
if (!img || !statsEl) return; if (!img || !statsEl) return;
if (thumbnailSrc) { if (thumbnailSrc) {
@@ -333,10 +365,53 @@ export function updateOverlayPreview(thumbnailSrc, stats) {
} }
} }
// ── Inline field validation ──
/** Mark a field as invalid with an error message. */
export function setFieldError(input: HTMLInputElement | HTMLSelectElement, message: string) {
input.classList.add('field-invalid');
clearFieldError(input); // remove existing
if (message) {
const msg = document.createElement('span');
msg.className = 'field-error-msg';
msg.textContent = message;
input.parentElement?.appendChild(msg);
}
}
/** Clear field error state. */
export function clearFieldError(input: HTMLInputElement | HTMLSelectElement) {
input.classList.remove('field-invalid');
const existing = input.parentElement?.querySelector('.field-error-msg');
if (existing) existing.remove();
}
/** Validate a required field on blur. Returns true if valid. */
export function validateRequired(input: HTMLInputElement | HTMLSelectElement, errorMsg?: string): boolean {
const value = input.value.trim();
if (!value) {
setFieldError(input, errorMsg || t('validation.required'));
return false;
}
clearFieldError(input);
return true;
}
/** Set up blur validation on required fields within a container. */
export function setupBlurValidation(container: HTMLElement) {
const fields = container.querySelectorAll('input[required], select[required]') as NodeListOf<HTMLInputElement | HTMLSelectElement>;
fields.forEach(field => {
field.addEventListener('blur', () => validateRequired(field));
field.addEventListener('input', () => {
if (field.classList.contains('field-invalid')) clearFieldError(field);
});
});
}
/** Toggle the thin loading bar on a tab panel during data refresh. /** Toggle the thin loading bar on a tab panel during data refresh.
* Delays showing the bar by 400ms so quick loads never flash it. */ * Delays showing the bar by 400ms so quick loads never flash it. */
const _refreshTimers = {}; const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};
export function setTabRefreshing(containerId, refreshing) { export function setTabRefreshing(containerId: string, refreshing: boolean) {
const panel = document.getElementById(containerId)?.closest('.tab-panel'); const panel = document.getElementById(containerId)?.closest('.tab-panel');
if (!panel) return; if (!panel) return;
if (refreshing) { if (refreshing) {
@@ -351,7 +426,7 @@ export function setTabRefreshing(containerId, refreshing) {
} }
/** Format a large number compactly: 999 → "999", 1200 → "1.2K", 2500000 → "2.5M" */ /** Format a large number compactly: 999 → "999", 1200 → "1.2K", 2500000 → "2.5M" */
export function formatCompact(n) { export function formatCompact(n: number | null | undefined) {
if (n == null || n < 0) return '-'; if (n == null || n < 0) return '-';
if (n < 1000) return String(n); if (n < 1000) return String(n);
if (n < 1_000_000) { if (n < 1_000_000) {
@@ -366,7 +441,7 @@ export function formatCompact(n) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
} }
export function formatUptime(seconds) { export function formatUptime(seconds: number | null | undefined) {
if (!seconds || seconds <= 0) return '-'; if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);

View File

@@ -5,13 +5,69 @@
* The canvas shows monitor rectangles that can be repositioned for visual clarity. * The canvas shows monitor rectangles that can be repositioned for visual clarity.
*/ */
import { API_BASE, fetchWithAuth } from '../core/api.js'; import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.js'; import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { getPictureSourceIcon } from '../core/icons.js'; import { getPictureSourceIcon } from '../core/icons.ts';
import type { Calibration, CalibrationLine, PictureSource } from '../types.ts';
/* ── Types ──────────────────────────────────────────────────── */
interface MonitorRect {
id: string;
name: string;
width: number;
height: number;
cx: number;
cy: number;
cw: number;
ch: number;
}
interface CalibrationState {
cssId: string | null;
lines: CalibrationLine[];
monitors: MonitorRect[];
pictureSources: PictureSource[];
totalLedCount: number;
selectedLine: number;
dragging: DragState | null;
}
interface DragMonitorState {
type: 'monitor';
monIdx: number;
startX: number;
startY: number;
origCx: number;
origCy: number;
}
interface DragPanState {
type: 'pan';
startX: number;
startY: number;
origPanX: number;
origPanY: number;
}
type DragState = DragMonitorState | DragPanState;
interface ViewState {
panX: number;
panY: number;
zoom: number;
}
interface LineCoords {
x1: number;
y1: number;
x2: number;
y2: number;
}
/* ── Constants ──────────────────────────────────────────────── */ /* ── Constants ──────────────────────────────────────────────── */
@@ -36,18 +92,18 @@ const LINE_THICKNESS_PX = 6;
/* ── State ──────────────────────────────────────────────────── */ /* ── State ──────────────────────────────────────────────────── */
let _state = { let _state: CalibrationState = {
cssId: null, cssId: null,
lines: [], // [{picture_source_id, edge, led_count, span_start, span_end, reverse, border_width}] lines: [],
monitors: [], // [{id, name, width, height, cx, cy, cw, ch}] — canvas coords monitors: [],
pictureSources: [], // raw API data pictureSources: [],
totalLedCount: 0, // total LED count from the CSS source totalLedCount: 0,
selectedLine: -1, selectedLine: -1,
dragging: null, // {type:'monitor'|'pan', ...} dragging: null,
}; };
// Zoom/pan view state // Zoom/pan view state
let _view = { panX: 0, panY: 0, zoom: 1.0 }; let _view: ViewState = { panX: 0, panY: 0, zoom: 1.0 };
const MIN_ZOOM = 0.25; const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4.0; const MAX_ZOOM = 4.0;
@@ -55,15 +111,15 @@ const MAX_ZOOM = 4.0;
class AdvancedCalibrationModal extends Modal { class AdvancedCalibrationModal extends Modal {
constructor() { super('advanced-calibration-modal'); } constructor() { super('advanced-calibration-modal'); }
snapshotValues() { snapshotValues(): Record<string, string> {
return { return {
lines: JSON.stringify(_state.lines), lines: JSON.stringify(_state.lines),
offset: document.getElementById('advcal-offset')?.value || '0', offset: (document.getElementById('advcal-offset') as HTMLInputElement)?.value || '0',
skipStart: document.getElementById('advcal-skip-start')?.value || '0', skipStart: (document.getElementById('advcal-skip-start') as HTMLInputElement)?.value || '0',
skipEnd: document.getElementById('advcal-skip-end')?.value || '0', skipEnd: (document.getElementById('advcal-skip-end') as HTMLInputElement)?.value || '0',
}; };
} }
onForceClose() { onForceClose(): void {
if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; } if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
_state.cssId = null; _state.cssId = null;
_state.lines = []; _state.lines = [];
@@ -76,7 +132,7 @@ const _modal = new AdvancedCalibrationModal();
/* ── Public API ─────────────────────────────────────────────── */ /* ── Public API ─────────────────────────────────────────────── */
export async function showAdvancedCalibration(cssId) { export async function showAdvancedCalibration(cssId: string): Promise<void> {
try { try {
const [cssSources, psResp] = await Promise.all([ const [cssSources, psResp] = await Promise.all([
colorStripSourcesCache.fetch(), colorStripSourcesCache.fetch(),
@@ -84,14 +140,14 @@ export async function showAdvancedCalibration(cssId) {
]); ]);
const source = cssSources.find(s => s.id === cssId); const source = cssSources.find(s => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration = source.calibration || {}; const calibration: Calibration = source.calibration || {} as Calibration;
const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
_state.cssId = cssId; _state.cssId = cssId;
_state.pictureSources = psList; _state.pictureSources = psList;
_state.totalLedCount = source.led_count || 0; _state.totalLedCount = source.led_count || 0;
document.getElementById('advcal-css-id').value = cssId; (document.getElementById('advcal-css-id') as HTMLInputElement).value = cssId;
// Populate picture source selector // Populate picture source selector
_populateSourceSelect(psList); _populateSourceSelect(psList);
@@ -103,9 +159,9 @@ export async function showAdvancedCalibration(cssId) {
_state.lines = []; _state.lines = [];
} }
document.getElementById('advcal-offset').value = calibration.offset || 0; (document.getElementById('advcal-offset') as HTMLInputElement).value = String(calibration.offset || 0);
document.getElementById('advcal-skip-start').value = calibration.skip_leds_start || 0; (document.getElementById('advcal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0);
document.getElementById('advcal-skip-end').value = calibration.skip_leds_end || 0; (document.getElementById('advcal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0);
// Build monitor rectangles from used picture sources and fit view // Build monitor rectangles from used picture sources and fit view
_rebuildUsedMonitors(); _rebuildUsedMonitors();
@@ -131,11 +187,11 @@ export async function showAdvancedCalibration(cssId) {
} }
} }
export async function closeAdvancedCalibration() { export async function closeAdvancedCalibration(): Promise<void> {
await _modal.close(); await _modal.close();
} }
export async function saveAdvancedCalibration() { export async function saveAdvancedCalibration(): Promise<void> {
const cssId = _state.cssId; const cssId = _state.cssId;
if (!cssId) return; if (!cssId) return;
@@ -155,9 +211,9 @@ export async function saveAdvancedCalibration() {
reverse: l.reverse, reverse: l.reverse,
border_width: l.border_width, border_width: l.border_width,
})), })),
offset: parseInt(document.getElementById('advcal-offset').value) || 0, offset: parseInt((document.getElementById('advcal-offset') as HTMLInputElement).value) || 0,
skip_leds_start: parseInt(document.getElementById('advcal-skip-start').value) || 0, skip_leds_start: parseInt((document.getElementById('advcal-skip-start') as HTMLInputElement).value) || 0,
skip_leds_end: parseInt(document.getElementById('advcal-skip-end').value) || 0, skip_leds_end: parseInt((document.getElementById('advcal-skip-end') as HTMLInputElement).value) || 0,
}; };
try { try {
@@ -172,15 +228,17 @@ export async function saveAdvancedCalibration() {
_modal.forceClose(); _modal.forceClose();
} else { } else {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
showToast(err.message || t('calibration.error.save_failed'), 'error'); const detail = err.detail || err.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('calibration.error.save_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('calibration.error.save_failed'), 'error'); showToast(error.message || t('calibration.error.save_failed'), 'error');
} }
} }
export function addCalibrationLine() { export function addCalibrationLine(): void {
const defaultSource = _state.pictureSources[0]?.id || ''; const defaultSource = _state.pictureSources[0]?.id || '';
const hadMonitors = _state.monitors.length; const hadMonitors = _state.monitors.length;
_state.lines.push({ _state.lines.push({
@@ -205,7 +263,7 @@ export function addCalibrationLine() {
_updateTotalLeds(); _updateTotalLeds();
} }
export function removeCalibrationLine(idx) { export function removeCalibrationLine(idx: number): void {
if (idx < 0 || idx >= _state.lines.length) return; if (idx < 0 || idx >= _state.lines.length) return;
_state.lines.splice(idx, 1); _state.lines.splice(idx, 1);
if (_state.selectedLine >= _state.lines.length) { if (_state.selectedLine >= _state.lines.length) {
@@ -218,7 +276,7 @@ export function removeCalibrationLine(idx) {
_updateTotalLeds(); _updateTotalLeds();
} }
export function selectCalibrationLine(idx) { export function selectCalibrationLine(idx: number): void {
const prev = _state.selectedLine; const prev = _state.selectedLine;
_state.selectedLine = idx; _state.selectedLine = idx;
// Update selection in-place without rebuilding the list DOM // Update selection in-place without rebuilding the list DOM
@@ -230,7 +288,7 @@ export function selectCalibrationLine(idx) {
_renderCanvas(); _renderCanvas();
} }
export function moveCalibrationLine(idx, direction) { export function moveCalibrationLine(idx: number, direction: number): void {
const newIdx = idx + direction; const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= _state.lines.length) return; if (newIdx < 0 || newIdx >= _state.lines.length) return;
[_state.lines[idx], _state.lines[newIdx]] = [_state.lines[newIdx], _state.lines[idx]]; [_state.lines[idx], _state.lines[newIdx]] = [_state.lines[newIdx], _state.lines[idx]];
@@ -240,18 +298,18 @@ export function moveCalibrationLine(idx, direction) {
_renderCanvas(); _renderCanvas();
} }
export function updateCalibrationLine() { export function updateCalibrationLine(): void {
const idx = _state.selectedLine; const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) return; if (idx < 0 || idx >= _state.lines.length) return;
const line = _state.lines[idx]; const line = _state.lines[idx];
const hadMonitors = _state.monitors.length; const hadMonitors = _state.monitors.length;
line.picture_source_id = document.getElementById('advcal-line-source').value; line.picture_source_id = (document.getElementById('advcal-line-source') as HTMLSelectElement).value;
line.edge = document.getElementById('advcal-line-edge').value; line.edge = (document.getElementById('advcal-line-edge') as HTMLSelectElement).value as CalibrationLine['edge'];
line.led_count = Math.max(1, parseInt(document.getElementById('advcal-line-leds').value) || 1); line.led_count = Math.max(1, parseInt((document.getElementById('advcal-line-leds') as HTMLInputElement).value) || 1);
line.span_start = parseFloat(document.getElementById('advcal-line-span-start').value) || 0; line.span_start = parseFloat((document.getElementById('advcal-line-span-start') as HTMLInputElement).value) || 0;
line.span_end = parseFloat(document.getElementById('advcal-line-span-end').value) || 1; line.span_end = parseFloat((document.getElementById('advcal-line-span-end') as HTMLInputElement).value) || 1;
line.border_width = Math.max(1, parseInt(document.getElementById('advcal-line-border-width').value) || 10); line.border_width = Math.max(1, parseInt((document.getElementById('advcal-line-border-width') as HTMLInputElement).value) || 10);
line.reverse = document.getElementById('advcal-line-reverse').checked; line.reverse = (document.getElementById('advcal-line-reverse') as HTMLInputElement).checked;
_rebuildUsedMonitors(); _rebuildUsedMonitors();
if (_state.monitors.length > hadMonitors) { if (_state.monitors.length > hadMonitors) {
_placeNewMonitor(); _placeNewMonitor();
@@ -264,10 +322,10 @@ export function updateCalibrationLine() {
/* ── Internals ──────────────────────────────────────────────── */ /* ── Internals ──────────────────────────────────────────────── */
let _lineSourceEntitySelect = null; let _lineSourceEntitySelect: EntitySelect | null = null;
function _populateSourceSelect(psList) { function _populateSourceSelect(psList: any[]) {
const sel = document.getElementById('advcal-line-source'); const sel = document.getElementById('advcal-line-source') as HTMLSelectElement;
sel.innerHTML = ''; sel.innerHTML = '';
psList.forEach(ps => { psList.forEach(ps => {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -290,7 +348,7 @@ function _populateSourceSelect(psList) {
}); });
} }
function _rebuildUsedMonitors() { function _rebuildUsedMonitors(): void {
const usedIds = new Set(_state.lines.map(l => l.picture_source_id)); const usedIds = new Set(_state.lines.map(l => l.picture_source_id));
const currentIds = new Set(_state.monitors.map(m => m.id)); const currentIds = new Set(_state.monitors.map(m => m.id));
// Only rebuild if the set of used sources changed // Only rebuild if the set of used sources changed
@@ -299,13 +357,13 @@ function _rebuildUsedMonitors() {
_buildMonitorLayout(usedSources, _state.cssId); _buildMonitorLayout(usedSources, _state.cssId);
} }
function _buildMonitorLayout(psList, cssId) { function _buildMonitorLayout(psList: any[], cssId: string | null): void {
// Load saved positions from localStorage // Load saved positions from localStorage
const savedKey = `advcal_positions_${cssId}`; const savedKey = `advcal_positions_${cssId}`;
let saved = {}; let saved = {};
try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ } try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
const canvas = document.getElementById('advcal-canvas'); const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
const canvasW = canvas.width; const canvasW = canvas.width;
const canvasH = canvas.height; const canvasH = canvas.height;
@@ -340,7 +398,7 @@ function _buildMonitorLayout(psList, cssId) {
_state.monitors = monitors; _state.monitors = monitors;
} }
function _saveMonitorPositions() { function _saveMonitorPositions(): void {
if (!_state.cssId) return; if (!_state.cssId) return;
const savedKey = `advcal_positions_${_state.cssId}`; const savedKey = `advcal_positions_${_state.cssId}`;
const positions = {}; const positions = {};
@@ -349,7 +407,7 @@ function _saveMonitorPositions() {
} }
/** Place the last monitor next to the rightmost existing one. */ /** Place the last monitor next to the rightmost existing one. */
function _placeNewMonitor() { function _placeNewMonitor(): void {
if (_state.monitors.length < 2) return; if (_state.monitors.length < 2) return;
const newMon = _state.monitors[_state.monitors.length - 1]; const newMon = _state.monitors[_state.monitors.length - 1];
const others = _state.monitors.slice(0, -1); const others = _state.monitors.slice(0, -1);
@@ -363,21 +421,21 @@ function _placeNewMonitor() {
_saveMonitorPositions(); _saveMonitorPositions();
} }
function _updateTotalLeds() { function _updateTotalLeds(): void {
const used = _state.lines.reduce((s, l) => s + l.led_count, 0); const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
const el = document.getElementById('advcal-total-leds'); const el = document.getElementById('advcal-total-leds');
if (_state.totalLedCount > 0) { if (_state.totalLedCount > 0) {
el.textContent = `${used}/${_state.totalLedCount}`; el.textContent = `${used}/${_state.totalLedCount}`;
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : ''; el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
} else { } else {
el.textContent = used; el.textContent = String(used);
el.style.color = ''; el.style.color = '';
} }
} }
/* ── Line list rendering ────────────────────────────────────── */ /* ── Line list rendering ────────────────────────────────────── */
function _renderLineList() { function _renderLineList(): void {
const container = document.getElementById('advcal-line-list'); const container = document.getElementById('advcal-line-list');
container.innerHTML = ''; container.innerHTML = '';
@@ -411,7 +469,7 @@ function _renderLineList() {
container.appendChild(addDiv); container.appendChild(addDiv);
} }
function _showLineProps() { function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props'); const propsEl = document.getElementById('advcal-line-props');
const idx = _state.selectedLine; const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) { if (idx < 0 || idx >= _state.lines.length) {
@@ -420,20 +478,20 @@ function _showLineProps() {
} }
propsEl.style.display = ''; propsEl.style.display = '';
const line = _state.lines[idx]; const line = _state.lines[idx];
document.getElementById('advcal-line-source').value = line.picture_source_id; (document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh(); if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
document.getElementById('advcal-line-edge').value = line.edge; (document.getElementById('advcal-line-edge') as HTMLSelectElement).value = line.edge;
document.getElementById('advcal-line-leds').value = line.led_count; (document.getElementById('advcal-line-leds') as HTMLInputElement).value = String(line.led_count);
document.getElementById('advcal-line-span-start').value = line.span_start; (document.getElementById('advcal-line-span-start') as HTMLInputElement).value = String(line.span_start);
document.getElementById('advcal-line-span-end').value = line.span_end; (document.getElementById('advcal-line-span-end') as HTMLInputElement).value = String(line.span_end);
document.getElementById('advcal-line-border-width').value = line.border_width; (document.getElementById('advcal-line-border-width') as HTMLInputElement).value = String(line.border_width);
document.getElementById('advcal-line-reverse').checked = line.reverse; (document.getElementById('advcal-line-reverse') as HTMLInputElement).checked = line.reverse;
} }
/* ── Coordinate helpers ─────────────────────────────────────── */ /* ── Coordinate helpers ─────────────────────────────────────── */
/** Convert a mouse event to world-space (pre-transform) canvas coordinates. */ /** Convert a mouse event to world-space (pre-transform) canvas coordinates. */
function _mouseToWorld(e, canvas) { function _mouseToWorld(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const sx = (e.clientX - rect.left) * (canvas.width / rect.width); const sx = (e.clientX - rect.left) * (canvas.width / rect.width);
const sy = (e.clientY - rect.top) * (canvas.height / rect.height); const sy = (e.clientY - rect.top) * (canvas.height / rect.height);
@@ -444,7 +502,7 @@ function _mouseToWorld(e, canvas) {
} }
/** Convert a mouse event to raw screen-space canvas coordinates (for pan). */ /** Convert a mouse event to raw screen-space canvas coordinates (for pan). */
function _mouseToScreen(e, canvas) { function _mouseToScreen(e: MouseEvent, canvas: HTMLCanvasElement): { x: number; y: number } {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
return { return {
x: (e.clientX - rect.left) * (canvas.width / rect.width), x: (e.clientX - rect.left) * (canvas.width / rect.width),
@@ -452,13 +510,13 @@ function _mouseToScreen(e, canvas) {
}; };
} }
export function resetCalibrationView() { export function resetCalibrationView(): void {
_fitView(); _fitView();
_renderCanvas(); _renderCanvas();
} }
function _fitView() { function _fitView(): void {
const canvas = document.getElementById('advcal-canvas'); const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas || _state.monitors.length === 0) { if (!canvas || _state.monitors.length === 0) {
_view = { panX: 0, panY: 0, zoom: 1.0 }; _view = { panX: 0, panY: 0, zoom: 1.0 };
return; return;
@@ -492,8 +550,8 @@ function _fitView() {
/* ── Canvas rendering ───────────────────────────────────────── */ /* ── Canvas rendering ───────────────────────────────────────── */
function _renderCanvas() { function _renderCanvas(): void {
const canvas = document.getElementById('advcal-canvas'); const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const W = canvas.width; const W = canvas.width;
@@ -557,7 +615,7 @@ function _renderCanvas() {
ctx.restore(); ctx.restore();
} }
function _getLineCoords(mon, line) { function _getLineCoords(mon: MonitorRect, line: CalibrationLine): LineCoords {
const s = line.span_start; const s = line.span_start;
const e = line.span_end; const e = line.span_end;
let x1, y1, x2, y2; let x1, y1, x2, y2;
@@ -587,7 +645,7 @@ function _getLineCoords(mon, line) {
return { x1, y1, x2, y2 }; return { x1, y1, x2, y2 };
} }
function _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected) { function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1: number, y1: number, x2: number, y2: number, ledStart: number, mon: MonitorRect, isSelected: boolean): void {
const count = line.led_count; const count = line.led_count;
if (count <= 0) return; if (count <= 0) return;
@@ -696,7 +754,7 @@ function _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected) {
ctx.restore(); ctx.restore();
} }
function _drawArrow(ctx, x1, y1, x2, y2, reverse, color) { function _drawArrow(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, reverse: boolean, color: string): void {
if (reverse) { [x1, y1, x2, y2] = [x2, y2, x1, y1]; } if (reverse) { [x1, y1, x2, y2] = [x2, y2, x1, y1]; }
const angle = Math.atan2(y2 - y1, x2 - x1); const angle = Math.atan2(y2 - y1, x2 - x1);
const headLen = 8; const headLen = 8;
@@ -714,8 +772,8 @@ function _drawArrow(ctx, x1, y1, x2, y2, reverse, color) {
/* ── Canvas interaction (drag monitors) ─────────────────────── */ /* ── Canvas interaction (drag monitors) ─────────────────────── */
function _initCanvasHandlers() { function _initCanvasHandlers(): void {
const canvas = document.getElementById('advcal-canvas'); const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas) return; if (!canvas) return;
canvas.oncontextmenu = (e) => e.preventDefault(); canvas.oncontextmenu = (e) => e.preventDefault();
@@ -846,7 +904,7 @@ function _initCanvasHandlers() {
}; };
} }
function _pointNearLine(px, py, x1, y1, x2, y2, threshold) { function _pointNearLine(px: number, py: number, x1: number, y1: number, x2: number, y2: number, threshold: number): boolean {
const dx = x2 - x1; const dx = x2 - x1;
const dy = y2 - y1; const dy = y2 - y1;
const lenSq = dx * dx + dy * dy; const lenSq = dx * dx + dy * dy;

View File

@@ -10,17 +10,17 @@
* This module manages the editor modal and API operations. * This module manages the editor modal and API operations.
*/ */
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js'; import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js'; import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { TagInput } from '../core/tag-input.js'; import { TagInput } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.js'; import { loadPictureSources } from './streams.ts';
let _audioSourceTagsInput = null; let _audioSourceTagsInput: TagInput | null = null;
class AudioSourceModal extends Modal { class AudioSourceModal extends Modal {
constructor() { super('audio-source-modal'); } constructor() { super('audio-source-modal'); }
@@ -31,13 +31,13 @@ class AudioSourceModal extends Modal {
snapshotValues() { snapshotValues() {
return { return {
name: document.getElementById('audio-source-name').value, name: (document.getElementById('audio-source-name') as HTMLInputElement).value,
description: document.getElementById('audio-source-description').value, description: (document.getElementById('audio-source-description') as HTMLInputElement).value,
type: document.getElementById('audio-source-type').value, type: (document.getElementById('audio-source-type') as HTMLSelectElement).value,
device: document.getElementById('audio-source-device').value, device: (document.getElementById('audio-source-device') as HTMLSelectElement).value,
audioTemplate: document.getElementById('audio-source-audio-template').value, audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value,
parent: document.getElementById('audio-source-parent').value, parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
channel: document.getElementById('audio-source-channel').value, channel: (document.getElementById('audio-source-channel') as HTMLSelectElement).value,
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []), tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
}; };
} }
@@ -46,48 +46,48 @@ class AudioSourceModal extends Modal {
const audioSourceModal = new AudioSourceModal(); const audioSourceModal = new AudioSourceModal();
// ── EntitySelect instances for audio source editor ── // ── EntitySelect instances for audio source editor ──
let _asTemplateEntitySelect = null; let _asTemplateEntitySelect: EntitySelect | null = null;
let _asDeviceEntitySelect = null; let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect = null; let _asParentEntitySelect: EntitySelect | null = null;
// ── Modal ───────────────────────────────────────────────────── // ── Modal ─────────────────────────────────────────────────────
export async function showAudioSourceModal(sourceType, editData) { export async function showAudioSourceModal(sourceType: any, editData?: any) {
const isEdit = !!editData; const isEdit = !!editData;
const titleKey = isEdit const titleKey = isEdit
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') ? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel')
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel'); : (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
document.getElementById('audio-source-modal-title').innerHTML = `${ICON_MUSIC} ${t(titleKey)}`; document.getElementById('audio-source-modal-title').innerHTML = `${ICON_MUSIC} ${t(titleKey)}`;
document.getElementById('audio-source-id').value = isEdit ? editData.id : ''; (document.getElementById('audio-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
document.getElementById('audio-source-error').style.display = 'none'; (document.getElementById('audio-source-error') as HTMLElement).style.display = 'none';
const typeSelect = document.getElementById('audio-source-type'); const typeSelect = document.getElementById('audio-source-type') as HTMLSelectElement;
typeSelect.value = isEdit ? editData.source_type : sourceType; typeSelect.value = isEdit ? editData.source_type : sourceType;
typeSelect.disabled = isEdit; // can't change type after creation typeSelect.disabled = isEdit; // can't change type after creation
onAudioSourceTypeChange(); onAudioSourceTypeChange();
if (isEdit) { if (isEdit) {
document.getElementById('audio-source-name').value = editData.name || ''; (document.getElementById('audio-source-name') as HTMLInputElement).value = editData.name || '';
document.getElementById('audio-source-description').value = editData.description || ''; (document.getElementById('audio-source-description') as HTMLInputElement).value = editData.description || '';
if (editData.source_type === 'multichannel') { if (editData.source_type === 'multichannel') {
_loadAudioTemplates(editData.audio_template_id); _loadAudioTemplates(editData.audio_template_id);
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate; (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices(); await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback); _selectAudioDevice(editData.device_index, editData.is_loopback);
} else { } else {
_loadMultichannelSources(editData.audio_source_id); _loadMultichannelSources(editData.audio_source_id);
document.getElementById('audio-source-channel').value = editData.channel || 'mono'; (document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
} }
} else { } else {
document.getElementById('audio-source-name').value = ''; (document.getElementById('audio-source-name') as HTMLInputElement).value = '';
document.getElementById('audio-source-description').value = ''; (document.getElementById('audio-source-description') as HTMLInputElement).value = '';
if (sourceType === 'multichannel') { if (sourceType === 'multichannel') {
_loadAudioTemplates(); _loadAudioTemplates();
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate; (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices(); await _loadAudioDevices();
} else { } else {
_loadMultichannelSources(); _loadMultichannelSources();
@@ -108,19 +108,19 @@ export async function closeAudioSourceModal() {
} }
export function onAudioSourceTypeChange() { export function onAudioSourceTypeChange() {
const type = document.getElementById('audio-source-type').value; const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none'; (document.getElementById('audio-source-multichannel-section') as HTMLElement).style.display = type === 'multichannel' ? '' : 'none';
document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none'; (document.getElementById('audio-source-mono-section') as HTMLElement).style.display = type === 'mono' ? '' : 'none';
} }
// ── Save ────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────
export async function saveAudioSource() { export async function saveAudioSource() {
const id = document.getElementById('audio-source-id').value; const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
const name = document.getElementById('audio-source-name').value.trim(); const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
const sourceType = document.getElementById('audio-source-type').value; const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
const description = document.getElementById('audio-source-description').value.trim() || null; const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
const errorEl = document.getElementById('audio-source-error'); const errorEl = document.getElementById('audio-source-error') as HTMLElement;
if (!name) { if (!name) {
errorEl.textContent = t('audio_source.error.name_required'); errorEl.textContent = t('audio_source.error.name_required');
@@ -128,17 +128,17 @@ export async function saveAudioSource() {
return; return;
} }
const payload = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] }; const payload: any = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
if (sourceType === 'multichannel') { if (sourceType === 'multichannel') {
const deviceVal = document.getElementById('audio-source-device').value || '-1:1'; const deviceVal = (document.getElementById('audio-source-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':'); const [devIdx, devLoop] = deviceVal.split(':');
payload.device_index = parseInt(devIdx) || -1; payload.device_index = parseInt(devIdx) || -1;
payload.is_loopback = devLoop !== '0'; payload.is_loopback = devLoop !== '0';
payload.audio_template_id = document.getElementById('audio-source-audio-template').value || null; payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null;
} else { } else {
payload.audio_source_id = document.getElementById('audio-source-parent').value; payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
payload.channel = document.getElementById('audio-source-channel').value; payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
} }
try { try {
@@ -157,7 +157,7 @@ export async function saveAudioSource() {
audioSourceModal.forceClose(); audioSourceModal.forceClose();
audioSourcesCache.invalidate(); audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e: any) {
errorEl.textContent = e.message; errorEl.textContent = e.message;
errorEl.style.display = ''; errorEl.style.display = '';
} }
@@ -165,13 +165,13 @@ export async function saveAudioSource() {
// ── Edit ────────────────────────────────────────────────────── // ── Edit ──────────────────────────────────────────────────────
export async function editAudioSource(sourceId) { export async function editAudioSource(sourceId: any) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load')); if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json(); const data = await resp.json();
await showAudioSourceModal(data.source_type, data); await showAudioSourceModal(data.source_type, data);
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
@@ -179,7 +179,7 @@ export async function editAudioSource(sourceId) {
// ── Clone ───────────────────────────────────────────────────── // ── Clone ─────────────────────────────────────────────────────
export async function cloneAudioSource(sourceId) { export async function cloneAudioSource(sourceId: any) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load')); if (!resp.ok) throw new Error(t('audio_source.error.load'));
@@ -187,7 +187,7 @@ export async function cloneAudioSource(sourceId) {
delete data.id; delete data.id;
data.name = data.name + ' (copy)'; data.name = data.name + ' (copy)';
await showAudioSourceModal(data.source_type, data); await showAudioSourceModal(data.source_type, data);
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
@@ -195,7 +195,7 @@ export async function cloneAudioSource(sourceId) {
// ── Delete ──────────────────────────────────────────────────── // ── Delete ────────────────────────────────────────────────────
export async function deleteAudioSource(sourceId) { export async function deleteAudioSource(sourceId: any) {
const confirmed = await showConfirm(t('audio_source.delete.confirm')); const confirmed = await showConfirm(t('audio_source.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
@@ -208,7 +208,7 @@ export async function deleteAudioSource(sourceId) {
showToast(t('audio_source.deleted'), 'success'); showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate(); audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e: any) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
@@ -216,7 +216,7 @@ export async function deleteAudioSource(sourceId) {
// ── Refresh devices ─────────────────────────────────────────── // ── Refresh devices ───────────────────────────────────────────
export async function refreshAudioDevices() { export async function refreshAudioDevices() {
const btn = document.getElementById('audio-source-refresh-devices'); const btn = document.getElementById('audio-source-refresh-devices') as HTMLButtonElement | null;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
await _loadAudioDevices(); await _loadAudioDevices();
@@ -242,13 +242,13 @@ async function _loadAudioDevices() {
} }
function _filterDevicesBySelectedTemplate() { function _filterDevicesBySelectedTemplate() {
const select = document.getElementById('audio-source-device'); const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
const prevOption = select.options[select.selectedIndex]; const prevOption = select.options[select.selectedIndex];
const prevName = prevOption ? prevOption.textContent : ''; const prevName = prevOption ? prevOption.textContent : '';
const templateId = (document.getElementById('audio-source-audio-template') || {}).value; const templateId = ((document.getElementById('audio-source-audio-template') as HTMLSelectElement | null) || { value: '' } as any).value;
const templates = _cachedAudioTemplates || []; const templates = _cachedAudioTemplates || [];
const template = templates.find(t => t.id === templateId); const template = templates.find(t => t.id === templateId);
const engineType = template ? template.engine_type : null; const engineType = template ? template.engine_type : null;
@@ -262,7 +262,7 @@ function _filterDevicesBySelectedTemplate() {
} }
} }
select.innerHTML = devices.map(d => { select.innerHTML = devices.map((d: any) => {
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(d.name)}</option>`; return `<option value="${val}">${escapeHtml(d.name)}</option>`;
}).join(''); }).join('');
@@ -272,7 +272,7 @@ function _filterDevicesBySelectedTemplate() {
} }
if (prevName) { if (prevName) {
const match = Array.from(select.options).find(o => o.textContent === prevName); const match = Array.from(select.options).find((o: HTMLOptionElement) => o.textContent === prevName);
if (match) select.value = match.value; if (match) select.value = match.value;
} }
@@ -280,27 +280,27 @@ function _filterDevicesBySelectedTemplate() {
if (devices.length > 0) { if (devices.length > 0) {
_asDeviceEntitySelect = new EntitySelect({ _asDeviceEntitySelect = new EntitySelect({
target: select, target: select,
getItems: () => devices.map(d => ({ getItems: () => devices.map((d: any) => ({
value: `${d.index}:${d.is_loopback ? '1' : '0'}`, value: `${d.index}:${d.is_loopback ? '1' : '0'}`,
label: d.name, label: d.name,
icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT, icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT,
desc: d.is_loopback ? 'Loopback' : 'Input', desc: d.is_loopback ? 'Loopback' : 'Input',
})), })),
placeholder: t('palette.search'), placeholder: t('palette.search'),
}); } as any);
} }
} }
function _selectAudioDevice(deviceIndex, isLoopback) { function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
const select = document.getElementById('audio-source-device'); const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`; const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
const opt = Array.from(select.options).find(o => o.value === val); const opt = Array.from(select.options).find((o: HTMLOptionElement) => o.value === val);
if (opt) select.value = val; if (opt) select.value = val;
} }
function _loadMultichannelSources(selectedId) { function _loadMultichannelSources(selectedId?: any) {
const select = document.getElementById('audio-source-parent'); const select = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
select.innerHTML = multichannel.map(s => select.innerHTML = multichannel.map(s =>
@@ -311,18 +311,18 @@ function _loadMultichannelSources(selectedId) {
if (multichannel.length > 0) { if (multichannel.length > 0) {
_asParentEntitySelect = new EntitySelect({ _asParentEntitySelect = new EntitySelect({
target: select, target: select,
getItems: () => multichannel.map(s => ({ getItems: () => multichannel.map((s: any) => ({
value: s.id, value: s.id,
label: s.name, label: s.name,
icon: getAudioSourceIcon('multichannel'), icon: getAudioSourceIcon('multichannel'),
})), })),
placeholder: t('palette.search'), placeholder: t('palette.search'),
}); } as any);
} }
} }
function _loadAudioTemplates(selectedId) { function _loadAudioTemplates(selectedId?: any) {
const select = document.getElementById('audio-source-audio-template'); const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
const templates = _cachedAudioTemplates || []; const templates = _cachedAudioTemplates || [];
select.innerHTML = templates.map(t => select.innerHTML = templates.map(t =>
@@ -333,14 +333,14 @@ function _loadAudioTemplates(selectedId) {
if (templates.length > 0) { if (templates.length > 0) {
_asTemplateEntitySelect = new EntitySelect({ _asTemplateEntitySelect = new EntitySelect({
target: select, target: select,
getItems: () => templates.map(tmpl => ({ getItems: () => templates.map((tmpl: any) => ({
value: tmpl.id, value: tmpl.id,
label: tmpl.name, label: tmpl.name,
icon: ICON_AUDIO_TEMPLATE, icon: ICON_AUDIO_TEMPLATE,
desc: tmpl.engine_type.toUpperCase(), desc: tmpl.engine_type.toUpperCase(),
})), })),
placeholder: t('palette.search'), placeholder: t('palette.search'),
}); } as any);
} }
} }
@@ -350,15 +350,15 @@ const NUM_BANDS = 64;
const PEAK_DECAY = 0.02; // peak drop per frame const PEAK_DECAY = 0.02; // peak drop per frame
const BEAT_FLASH_DECAY = 0.06; // beat flash fade per frame const BEAT_FLASH_DECAY = 0.06; // beat flash fade per frame
let _testAudioWs = null; let _testAudioWs: WebSocket | null = null;
let _testAudioAnimFrame = null; let _testAudioAnimFrame: number | null = null;
let _testAudioLatest = null; let _testAudioLatest: any = null;
let _testAudioPeaks = new Float32Array(NUM_BANDS); let _testAudioPeaks = new Float32Array(NUM_BANDS);
let _testBeatFlash = 0; let _testBeatFlash = 0;
const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true }); const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true });
export function testAudioSource(sourceId) { export function testAudioSource(sourceId: any) {
const statusEl = document.getElementById('audio-test-status'); const statusEl = document.getElementById('audio-test-status');
if (statusEl) { if (statusEl) {
statusEl.textContent = t('audio_source.test.connecting'); statusEl.textContent = t('audio_source.test.connecting');
@@ -377,7 +377,7 @@ export function testAudioSource(sourceId) {
testAudioModal.open(); testAudioModal.open();
// Size canvas to container // Size canvas to container
const canvas = document.getElementById('audio-test-canvas'); const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement;
_sizeCanvas(canvas); _sizeCanvas(canvas);
// Connect WebSocket // Connect WebSocket
@@ -433,7 +433,7 @@ function _cleanupTest() {
_testAudioLatest = null; _testAudioLatest = null;
} }
function _sizeCanvas(canvas) { function _sizeCanvas(canvas: HTMLCanvasElement) {
const rect = canvas.parentElement.getBoundingClientRect(); const rect = canvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr; canvas.width = rect.width * dpr;
@@ -450,7 +450,7 @@ function _renderLoop() {
} }
function _renderAudioSpectrum() { function _renderAudioSpectrum() {
const canvas = document.getElementById('audio-test-canvas'); const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');

View File

@@ -2,24 +2,25 @@
* Automations automation cards, editor, condition builder, process picker, scene selector. * Automations automation cards, editor, condition builder, process picker, scene selector.
*/ */
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js'; import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge } from './tabs.js'; import { updateTabBadge } from './tabs.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF } from '../core/icons.ts';
import * as P from '../core/icon-paths.js'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getBaseOrigin } from './settings.js'; import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.js'; import { attachProcessPicker } from '../core/process-picker.ts';
import { csScenes, createSceneCard } from './scene-presets.js'; import { csScenes, createSceneCard } from './scene-presets.ts';
import type { Automation } from '../types.ts';
let _automationTagsInput = null; let _automationTagsInput: any = null;
class AutomationEditorModal extends Modal { class AutomationEditorModal extends Modal {
constructor() { super('automation-editor-modal'); } constructor() { super('automation-editor-modal'); }
@@ -30,26 +31,65 @@ class AutomationEditorModal extends Modal {
snapshotValues() { snapshotValues() {
return { return {
name: document.getElementById('automation-editor-name').value, name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
enabled: document.getElementById('automation-editor-enabled').checked.toString(), enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
logic: document.getElementById('automation-editor-logic').value, logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
conditions: JSON.stringify(getAutomationEditorConditions()), conditions: JSON.stringify(getAutomationEditorConditions()),
scenePresetId: document.getElementById('automation-scene-id').value, scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
deactivationMode: document.getElementById('automation-deactivation-mode').value, deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value, deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
}; };
} }
} }
const automationModal = new AutomationEditorModal(); const automationModal = new AutomationEditorModal();
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations' });
// ── Bulk action handlers ──
async function _bulkEnableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning');
else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
async function _bulkDisableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning');
else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
async function _bulkDeleteAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations', bulkActions: [
{ key: 'enable', labelKey: 'bulk.enable', icon: ICON_OK, handler: _bulkEnableAutomations },
{ key: 'disable', labelKey: 'bulk.disable', icon: ICON_CIRCLE_OFF, handler: _bulkDisableAutomations },
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteAutomations },
] } as any);
/* ── Condition logic IconSelect ───────────────────────────────── */ /* ── Condition logic IconSelect ───────────────────────────────── */
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect = null; let _conditionLogicIconSelect: any = null;
function _ensureConditionLogicIconSelect() { function _ensureConditionLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic'); const sel = document.getElementById('automation-editor-logic');
@@ -59,7 +99,7 @@ function _ensureConditionLogicIconSelect() {
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') }, { value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
]; ];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; } if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 }); _conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
} }
// Re-render automations when language changes (only if tab is active) // Re-render automations when language changes (only if tab is active)
@@ -91,7 +131,7 @@ export async function loadAutomations() {
const activeCount = automations.filter(a => a.is_active).length; const activeCount = automations.filter(a => a.is_active).length;
updateTabBadge('automations', activeCount); updateTabBadge('automations', activeCount);
renderAutomations(automations, sceneMap); renderAutomations(automations, sceneMap);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load automations:', error); console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`; container.innerHTML = `<p class="error-message">${error.message}</p>`;
@@ -101,15 +141,7 @@ export async function loadAutomations() {
} }
} }
export function expandAllAutomationSections() { function renderAutomations(automations: any, sceneMap: any) {
CardSection.expandAll([csAutomations, csScenes]);
}
export function collapseAllAutomationSections() {
CardSection.collapseAll([csAutomations, csScenes]);
}
function renderAutomations(automations, sceneMap) {
const container = document.getElementById('automations-content'); const container = document.getElementById('automations-content');
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) }))); const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
@@ -119,7 +151,7 @@ function renderAutomations(automations, sceneMap) {
csAutomations.reconcile(autoItems); csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems); csScenes.reconcile(sceneItems);
} else { } else {
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`; const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
csAutomations.bind(); csAutomations.bind();
csScenes.bind(); csScenes.bind();
@@ -131,7 +163,7 @@ function renderAutomations(automations, sceneMap) {
} }
} }
function createAutomationCard(automation, sceneMap = new Map()) { function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive'; const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive'); const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
@@ -221,15 +253,15 @@ function createAutomationCard(automation, sceneMap = new Map()) {
}); });
} }
export async function openAutomationEditor(automationId, cloneData) { export async function openAutomationEditor(automationId?: any, cloneData?: any) {
const modal = document.getElementById('automation-editor-modal'); const modal = document.getElementById('automation-editor-modal');
const titleEl = document.getElementById('automation-editor-title'); const titleEl = document.getElementById('automation-editor-title');
const idInput = document.getElementById('automation-editor-id'); const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
const nameInput = document.getElementById('automation-editor-name'); const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled'); const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic'); const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
const condList = document.getElementById('automation-conditions-list'); const condList = document.getElementById('automation-conditions-list');
const errorEl = document.getElementById('automation-editor-error'); const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
errorEl.style.display = 'none'; errorEl.style.display = 'none';
condList.innerHTML = ''; condList.innerHTML = '';
@@ -243,11 +275,11 @@ export async function openAutomationEditor(automationId, cloneData) {
} catch { /* use cached */ } } catch { /* use cached */ }
// Reset deactivation mode // Reset deactivation mode
document.getElementById('automation-deactivation-mode').value = 'none'; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
document.getElementById('automation-fallback-scene-group').style.display = 'none'; (document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
let _editorTags = []; let _editorTags: any[] = [];
if (automationId) { if (automationId) {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`; titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
@@ -271,12 +303,12 @@ export async function openAutomationEditor(automationId, cloneData) {
// Deactivation mode // Deactivation mode
const deactMode = automation.deactivation_mode || 'none'; const deactMode = automation.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = deactMode; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = deactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_editorTags = automation.tags || []; _editorTags = automation.tags || [];
} catch (e) { } catch (e: any) {
showToast(e.message, 'error'); showToast(e.message, 'error');
return; return;
} }
@@ -299,7 +331,7 @@ export async function openAutomationEditor(automationId, cloneData) {
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id); _initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
const cloneDeactMode = cloneData.deactivation_mode || 'none'; const cloneDeactMode = cloneData.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = cloneDeactMode; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = cloneDeactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
@@ -316,14 +348,14 @@ export async function openAutomationEditor(automationId, cloneData) {
} }
// Wire up deactivation mode change // Wire up deactivation mode change
document.getElementById('automation-deactivation-mode').onchange = _onDeactivationModeChange; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
automationModal.open(); automationModal.open();
modal.querySelectorAll('[data-i18n]').forEach(el => { modal.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n')); el.textContent = t(el.getAttribute('data-i18n'));
}); });
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => { modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder')); (el as HTMLInputElement).placeholder = t(el.getAttribute('data-i18n-placeholder'));
}); });
// Tags // Tags
@@ -335,8 +367,8 @@ export async function openAutomationEditor(automationId, cloneData) {
} }
function _onDeactivationModeChange() { function _onDeactivationModeChange() {
const mode = document.getElementById('automation-deactivation-mode').value; const mode = (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value;
document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none'; (document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = mode === 'fallback_scene' ? '' : 'none';
} }
export async function closeAutomationEditorModal() { export async function closeAutomationEditorModal() {
@@ -345,8 +377,8 @@ export async function closeAutomationEditorModal() {
// ===== Scene selector (EntitySelect) ===== // ===== Scene selector (EntitySelect) =====
let _sceneEntitySelect = null; let _sceneEntitySelect: any = null;
let _fallbackSceneEntitySelect = null; let _fallbackSceneEntitySelect: any = null;
function _getSceneItems() { function _getSceneItems() {
return (scenePresetsCache.data || []).map(s => ({ return (scenePresetsCache.data || []).map(s => ({
@@ -356,8 +388,8 @@ function _getSceneItems() {
})); }));
} }
function _initSceneSelector(selectId, selectedId) { function _initSceneSelector(selectId: any, selectedId: any) {
const sel = document.getElementById(selectId); const sel = document.getElementById(selectId) as HTMLSelectElement;
// Populate <select> with scene options // Populate <select> with scene options
sel.innerHTML = (scenePresetsCache.data || []).map(s => sel.innerHTML = (scenePresetsCache.data || []).map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>` `<option value="${s.id}">${escapeHtml(s.name)}</option>`
@@ -375,7 +407,7 @@ function _initSceneSelector(selectId, selectedId) {
placeholder: t('automations.scene.search_placeholder'), placeholder: t('automations.scene.search_placeholder'),
allowNone: true, allowNone: true,
noneLabel: t('automations.scene.none_selected'), noneLabel: t('automations.scene.none_selected'),
}); } as any);
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es; if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
} }
@@ -385,7 +417,7 @@ const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
const DEACT_MODE_ICONS = { const DEACT_MODE_ICONS = {
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles, none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
}; };
let _deactivationModeIconSelect = null; let _deactivationModeIconSelect: any = null;
function _ensureDeactivationModeIconSelect() { function _ensureDeactivationModeIconSelect() {
const sel = document.getElementById('automation-deactivation-mode'); const sel = document.getElementById('automation-deactivation-mode');
@@ -396,7 +428,7 @@ function _ensureDeactivationModeIconSelect() {
label: t(`automations.deactivation_mode.${k}`), label: t(`automations.deactivation_mode.${k}`),
desc: t(`automations.deactivation_mode.${k}.desc`), desc: t(`automations.deactivation_mode.${k}.desc`),
})); }));
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 }); _deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
} }
// ===== Condition editor ===== // ===== Condition editor =====
@@ -435,7 +467,7 @@ function _buildConditionTypeItems() {
})); }));
} }
function addAutomationConditionRow(condition) { function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list'); const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'automation-condition-row'; row.className = 'automation-condition-row';
@@ -451,17 +483,17 @@ function addAutomationConditionRow(condition) {
<div class="condition-fields-container"></div> <div class="condition-fields-container"></div>
`; `;
const typeSelect = row.querySelector('.condition-type-select'); const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const container = row.querySelector('.condition-fields-container'); const container = row.querySelector('.condition-fields-container') as HTMLElement;
// Attach IconSelect to the condition type dropdown // Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({ const condIconSelect = new IconSelect({
target: typeSelect, target: typeSelect,
items: _buildConditionTypeItems(), items: _buildConditionTypeItems(),
columns: 4, columns: 4,
}); } as any);
function renderFields(type, data) { function renderFields(type: any, data: any) {
if (type === 'always') { if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`; container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return; return;
@@ -595,7 +627,7 @@ function addAutomationConditionRow(condition) {
</div> </div>
</div> </div>
`; `;
const textarea = container.querySelector('.condition-apps'); const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement;
attachProcessPicker(container, textarea); attachProcessPicker(container, textarea);
// Attach IconSelect to match type // Attach IconSelect to match type
@@ -605,7 +637,7 @@ function addAutomationConditionRow(condition) {
target: matchSel, target: matchSel,
items: _buildMatchTypeItems(), items: _buildMatchTypeItems(),
columns: 2, columns: 2,
}); } as any);
} }
} }
@@ -621,9 +653,9 @@ function addAutomationConditionRow(condition) {
function getAutomationEditorConditions() { function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row'); const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions = []; const conditions: any[] = [];
rows.forEach(row => { rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select'); const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const condType = typeSelect ? typeSelect.value : 'application'; const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') { if (condType === 'always') {
conditions.push({ condition_type: 'always' }); conditions.push({ condition_type: 'always' });
@@ -632,35 +664,35 @@ function getAutomationEditorConditions() {
} else if (condType === 'time_of_day') { } else if (condType === 'time_of_day') {
conditions.push({ conditions.push({
condition_type: 'time_of_day', condition_type: 'time_of_day',
start_time: row.querySelector('.condition-start-time').value || '00:00', start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00',
end_time: row.querySelector('.condition-end-time').value || '23:59', end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59',
}); });
} else if (condType === 'system_idle') { } else if (condType === 'system_idle') {
conditions.push({ conditions.push({
condition_type: 'system_idle', condition_type: 'system_idle',
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5, idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: row.querySelector('.condition-when-idle').value === 'true', when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true',
}); });
} else if (condType === 'display_state') { } else if (condType === 'display_state') {
conditions.push({ conditions.push({
condition_type: 'display_state', condition_type: 'display_state',
state: row.querySelector('.condition-display-state').value || 'on', state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on',
}); });
} else if (condType === 'mqtt') { } else if (condType === 'mqtt') {
conditions.push({ conditions.push({
condition_type: 'mqtt', condition_type: 'mqtt',
topic: row.querySelector('.condition-mqtt-topic').value.trim(), topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(),
payload: row.querySelector('.condition-mqtt-payload').value, payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value,
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact', match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
}); });
} else if (condType === 'webhook') { } else if (condType === 'webhook') {
const tokenInput = row.querySelector('.condition-webhook-token'); const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement;
const cond = { condition_type: 'webhook' }; const cond: any = { condition_type: 'webhook' };
if (tokenInput && tokenInput.value) cond.token = tokenInput.value; if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
conditions.push(cond); conditions.push(cond);
} else { } else {
const matchType = row.querySelector('.condition-match-type').value; const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
const appsText = row.querySelector('.condition-apps').value.trim(); const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType }); conditions.push({ condition_type: 'application', apps, match_type: matchType });
} }
@@ -669,10 +701,10 @@ function getAutomationEditorConditions() {
} }
export async function saveAutomationEditor() { export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id'); const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
const nameInput = document.getElementById('automation-editor-name'); const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled'); const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic'); const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
const name = nameInput.value.trim(); const name = nameInput.value.trim();
if (!name) { if (!name) {
@@ -685,9 +717,9 @@ export async function saveAutomationEditor() {
enabled: enabledInput.checked, enabled: enabledInput.checked,
condition_logic: logicSelect.value, condition_logic: logicSelect.value,
conditions: getAutomationEditorConditions(), conditions: getAutomationEditorConditions(),
scene_preset_id: document.getElementById('automation-scene-id').value || null, scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null,
deactivation_mode: document.getElementById('automation-deactivation-mode').value, deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null, deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [], tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
}; };
@@ -709,29 +741,32 @@ export async function saveAutomationEditor() {
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success'); showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
automationModal.showError(e.message); automationModal.showError(e.message);
} }
} }
export async function toggleAutomationEnabled(automationId, enable) { export async function toggleAutomationEnabled(automationId: any, enable: any) {
try { try {
const action = enable ? 'enable' : 'disable'; const action = enable ? 'enable' : 'disable';
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, { const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
method: 'POST', method: 'POST',
}); });
if (!resp.ok) throw new Error(`Failed to ${action} automation`); if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to ${action} automation`);
}
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
export function copyWebhookUrl(btn) { export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url'); const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement;
if (!input || !input.value) return; if (!input || !input.value) return;
const onCopied = () => { const onCopied = () => {
const orig = btn.textContent; const orig = btn.textContent;
@@ -747,19 +782,19 @@ export function copyWebhookUrl(btn) {
} }
} }
export async function cloneAutomation(automationId) { export async function cloneAutomation(automationId: any) {
try { try {
const resp = await fetchWithAuth(`/automations/${automationId}`); const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation'); if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json(); const automation = await resp.json();
openAutomationEditor(null, automation); openAutomationEditor(null, automation);
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(t('automations.error.clone_failed'), 'error'); showToast(t('automations.error.clone_failed'), 'error');
} }
} }
export async function deleteAutomation(automationId, automationName) { export async function deleteAutomation(automationId: any, automationName: any) {
const msg = t('automations.delete.confirm').replace('{name}', automationName); const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg); const confirmed = await showConfirm(msg);
if (!confirmed) return; if (!confirmed) return;
@@ -768,11 +803,14 @@ export async function deleteAutomation(automationId, automationName) {
const resp = await fetchWithAuth(`/automations/${automationId}`, { const resp = await fetchWithAuth(`/automations/${automationId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!resp.ok) throw new Error('Failed to delete automation'); if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to delete automation');
}
showToast(t('automations.deleted'), 'success'); showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }

View File

@@ -4,15 +4,16 @@
import { import {
calibrationTestState, EDGE_TEST_COLORS, displaysCache, calibrationTestState, EDGE_TEST_COLORS, displaysCache,
} from '../core/state.js'; } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts';
import { colorStripSourcesCache, devicesCache } from '../core/state.js'; import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; import { closeTutorial, startCalibrationTutorial } from './tutorials.ts';
import { startCSSOverlay, stopCSSOverlay } from './color-strips.js'; import { startCSSOverlay, stopCSSOverlay } from './color-strips.ts';
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.js'; import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts';
import type { Calibration } from '../types.ts';
/* ── CalibrationModal subclass ────────────────────────────────── */ /* ── CalibrationModal subclass ────────────────────────────────── */
@@ -23,35 +24,35 @@ class CalibrationModal extends Modal {
snapshotValues() { snapshotValues() {
return { return {
start_position: this.$('cal-start-position').value, start_position: (this.$('cal-start-position') as HTMLSelectElement).value,
layout: this.$('cal-layout').value, layout: (this.$('cal-layout') as HTMLSelectElement).value,
offset: this.$('cal-offset').value, offset: (this.$('cal-offset') as HTMLInputElement).value,
top: this.$('cal-top-leds').value, top: (this.$('cal-top-leds') as HTMLInputElement).value,
right: this.$('cal-right-leds').value, right: (this.$('cal-right-leds') as HTMLInputElement).value,
bottom: this.$('cal-bottom-leds').value, bottom: (this.$('cal-bottom-leds') as HTMLInputElement).value,
left: this.$('cal-left-leds').value, left: (this.$('cal-left-leds') as HTMLInputElement).value,
spans: JSON.stringify(window.edgeSpans), spans: JSON.stringify(window.edgeSpans),
skip_start: this.$('cal-skip-start').value, skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
skip_end: this.$('cal-skip-end').value, skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
border_width: this.$('cal-border-width').value, border_width: (this.$('cal-border-width') as HTMLInputElement).value,
led_count: this.$('cal-css-led-count').value, led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
}; };
} }
onForceClose() { onForceClose() {
closeTutorial(); closeTutorial();
if (_isCSS()) { if (_isCSS()) {
const cssId = document.getElementById('calibration-css-id')?.value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
if (_overlayStartedHere && cssId) { if (_overlayStartedHere && cssId) {
stopCSSOverlay(cssId); stopCSSOverlay(cssId);
_overlayStartedHere = false; _overlayStartedHere = false;
} }
_clearCSSTestMode(); _clearCSSTestMode();
document.getElementById('calibration-css-id').value = ''; (document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
const testGroup = document.getElementById('calibration-css-test-group'); const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none'; if (testGroup) testGroup.style.display = 'none';
} else { } else {
const deviceId = this.$('calibration-device-id').value; const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId); if (deviceId) clearTestMode(deviceId);
} }
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
@@ -62,26 +63,26 @@ class CalibrationModal extends Modal {
const calibModal = new CalibrationModal(); const calibModal = new CalibrationModal();
let _dragRaf = null; let _dragRaf: number | null = null;
let _previewRaf = null; let _previewRaf: number | null = null;
let _overlayStartedHere = false; let _overlayStartedHere = false;
/* ── Helpers ──────────────────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────────────────── */
function _isCSS() { function _isCSS() {
return !!(document.getElementById('calibration-css-id')?.value); return !!((document.getElementById('calibration-css-id') as HTMLInputElement)?.value);
} }
function _cssStateKey() { function _cssStateKey() {
return `css_${document.getElementById('calibration-css-id').value}`; return `css_${(document.getElementById('calibration-css-id') as HTMLInputElement).value}`;
} }
async function _clearCSSTestMode() { async function _clearCSSTestMode() {
const cssId = document.getElementById('calibration-css-id')?.value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
const stateKey = _cssStateKey(); const stateKey = _cssStateKey();
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return; if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
calibrationTestState[stateKey] = new Set(); calibrationTestState[stateKey] = new Set();
const testDeviceId = document.getElementById('calibration-test-device')?.value; const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
if (!testDeviceId) return; if (!testDeviceId) return;
try { try {
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
@@ -93,13 +94,13 @@ async function _clearCSSTestMode() {
} }
} }
function _setOverlayBtnActive(active) { function _setOverlayBtnActive(active: any) {
const btn = document.getElementById('calibration-overlay-btn'); const btn = document.getElementById('calibration-overlay-btn');
if (!btn) return; if (!btn) return;
btn.classList.toggle('active', active); btn.classList.toggle('active', active);
} }
async function _checkOverlayStatus(cssId) { async function _checkOverlayStatus(cssId: any) {
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
if (resp.ok) { if (resp.ok) {
@@ -110,7 +111,7 @@ async function _checkOverlayStatus(cssId) {
} }
export async function toggleCalibrationOverlay() { export async function toggleCalibrationOverlay() {
const cssId = document.getElementById('calibration-css-id')?.value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
if (!cssId) return; if (!cssId) return;
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
@@ -125,7 +126,7 @@ export async function toggleCalibrationOverlay() {
_setOverlayBtnActive(true); _setOverlayBtnActive(true);
_overlayStartedHere = true; _overlayStartedHere = true;
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle calibration overlay:', err); console.error('Failed to toggle calibration overlay:', err);
} }
@@ -133,7 +134,7 @@ export async function toggleCalibrationOverlay() {
/* ── Public API (exported names unchanged) ────────────────────── */ /* ── Public API (exported names unchanged) ────────────────────── */
export async function showCalibration(deviceId) { export async function showCalibration(deviceId: any) {
try { try {
const [response, displays] = await Promise.all([ const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`), fetchWithAuth(`/devices/${deviceId}`),
@@ -145,34 +146,34 @@ export async function showCalibration(deviceId) {
const device = await response.json(); const device = await response.json();
const calibration = device.calibration; const calibration = device.calibration;
const preview = document.querySelector('.calibration-preview'); const preview = document.querySelector('.calibration-preview') as HTMLElement;
const displayIndex = device.settings?.display_index ?? 0; const displayIndex = device.settings?.display_index ?? 0;
const display = displays.find(d => d.index === displayIndex); const display = displays.find((d: any) => d.index === displayIndex);
if (display && display.width && display.height) { if (display && display.width && display.height) {
preview.style.aspectRatio = `${display.width} / ${display.height}`; preview.style.aspectRatio = `${display.width} / ${display.height}`;
} else { } else {
preview.style.aspectRatio = ''; preview.style.aspectRatio = '';
} }
document.getElementById('calibration-device-id').value = device.id; (document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id;
document.getElementById('cal-device-led-count-inline').textContent = device.led_count; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count;
document.getElementById('cal-css-led-count-group').style.display = 'none'; (document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none';
document.getElementById('calibration-overlay-btn').style.display = 'none'; (document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none';
document.getElementById('cal-start-position').value = calibration.start_position; (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout; (document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout;
document.getElementById('cal-offset').value = calibration.offset || 0; (document.getElementById('cal-offset') as HTMLInputElement).value = calibration.offset || 0;
document.getElementById('cal-top-leds').value = calibration.leds_top || 0; (document.getElementById('cal-top-leds') as HTMLInputElement).value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0; (document.getElementById('cal-right-leds') as HTMLInputElement).value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; (document.getElementById('cal-bottom-leds') as HTMLInputElement).value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0; (document.getElementById('cal-left-leds') as HTMLInputElement).value = calibration.leds_left || 0;
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; (document.getElementById('cal-skip-start') as HTMLInputElement).value = calibration.skip_leds_start || 0;
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; (document.getElementById('cal-skip-end') as HTMLInputElement).value = calibration.skip_leds_end || 0;
updateOffsetSkipLock(); updateOffsetSkipLock();
document.getElementById('cal-border-width').value = calibration.border_width || 10; (document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
window.edgeSpans = { window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -209,7 +210,7 @@ export async function showCalibration(deviceId) {
} }
window._calibrationResizeObserver.observe(preview); window._calibrationResizeObserver.observe(preview);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load calibration:', error); console.error('Failed to load calibration:', error);
showToast(t('calibration.error.load_failed'), 'error'); showToast(t('calibration.error.load_failed'), 'error');
@@ -230,71 +231,70 @@ export async function closeCalibrationModal() {
/* ── CSS Calibration support ──────────────────────────────────── */ /* ── CSS Calibration support ──────────────────────────────────── */
export async function showCSSCalibration(cssId) { export async function showCSSCalibration(cssId: any) {
try { try {
const [cssSources, devices] = await Promise.all([ const [cssSources, devices] = await Promise.all([
colorStripSourcesCache.fetch(), colorStripSourcesCache.fetch(),
devicesCache.fetch().catch(() => []), devicesCache.fetch().catch(() => []),
]); ]);
const source = cssSources.find(s => s.id === cssId); const source = cssSources.find((s: any) => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration = source.calibration || { const calibration: Calibration = source.calibration || {} as Calibration;
}
// Set CSS mode — clear device-id, set css-id // Set CSS mode — clear device-id, set css-id
document.getElementById('calibration-device-id').value = ''; (document.getElementById('calibration-device-id') as HTMLInputElement).value = '';
document.getElementById('calibration-css-id').value = cssId; (document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId;
// Populate device picker for edge test // Populate device picker for edge test
const testDeviceSelect = document.getElementById('calibration-test-device'); const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement;
testDeviceSelect.innerHTML = ''; testDeviceSelect.innerHTML = '';
devices.forEach(d => { devices.forEach((d: any) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.id; opt.value = d.id;
opt.textContent = d.name; opt.textContent = d.name;
testDeviceSelect.appendChild(opt); testDeviceSelect.appendChild(opt);
}); });
const testGroup = document.getElementById('calibration-css-test-group'); const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
testGroup.style.display = devices.length ? '' : 'none'; testGroup.style.display = devices.length ? '' : 'none';
// Pre-select device: 1) LED count match, 2) last remembered, 3) first // Pre-select device: 1) LED count match, 2) last remembered, 3) first
if (devices.length) { if (devices.length) {
const rememberedId = localStorage.getItem('css_calibration_test_device'); const rememberedId = localStorage.getItem('css_calibration_test_device');
let selected = null; let selected: any = null;
if (source.led_count > 0) { if (source.led_count > 0) {
selected = devices.find(d => d.led_count === source.led_count) || null; selected = devices.find((d: any) => d.led_count === source.led_count) || null;
} }
if (!selected && rememberedId) { if (!selected && rememberedId) {
selected = devices.find(d => d.id === rememberedId) || null; selected = devices.find((d: any) => d.id === rememberedId) || null;
} }
if (selected) testDeviceSelect.value = selected.id; if (selected) testDeviceSelect.value = selected.id;
testDeviceSelect.onchange = () => localStorage.setItem('css_calibration_test_device', testDeviceSelect.value); testDeviceSelect.onchange = () => localStorage.setItem('css_calibration_test_device', testDeviceSelect.value);
} }
// Populate calibration fields // Populate calibration fields
const preview = document.querySelector('.calibration-preview'); const preview = document.querySelector('.calibration-preview') as HTMLElement;
preview.style.aspectRatio = ''; preview.style.aspectRatio = '';
document.getElementById('cal-device-led-count-inline').textContent = '—'; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = '—';
const ledCountGroup = document.getElementById('cal-css-led-count-group'); const ledCountGroup = document.getElementById('cal-css-led-count-group') as HTMLElement;
ledCountGroup.style.display = ''; ledCountGroup.style.display = '';
const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) + const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) +
(calibration.leds_bottom || 0) + (calibration.leds_left || 0); (calibration.leds_bottom || 0) + (calibration.leds_left || 0);
document.getElementById('cal-css-led-count').value = source.led_count || calLeds || 0; (document.getElementById('cal-css-led-count') as HTMLInputElement).value = String(source.led_count || calLeds || 0);
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left'; (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position || 'bottom_left';
document.getElementById('cal-layout').value = calibration.layout || 'clockwise'; (document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout || 'clockwise';
document.getElementById('cal-offset').value = calibration.offset || 0; (document.getElementById('cal-offset') as HTMLInputElement).value = String(calibration.offset || 0);
document.getElementById('cal-top-leds').value = calibration.leds_top || 0; (document.getElementById('cal-top-leds') as HTMLInputElement).value = String(calibration.leds_top || 0);
document.getElementById('cal-right-leds').value = calibration.leds_right || 0; (document.getElementById('cal-right-leds') as HTMLInputElement).value = String(calibration.leds_right || 0);
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; (document.getElementById('cal-bottom-leds') as HTMLInputElement).value = String(calibration.leds_bottom || 0);
document.getElementById('cal-left-leds').value = calibration.leds_left || 0; (document.getElementById('cal-left-leds') as HTMLInputElement).value = String(calibration.leds_left || 0);
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; (document.getElementById('cal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0);
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; (document.getElementById('cal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0);
updateOffsetSkipLock(); updateOffsetSkipLock();
document.getElementById('cal-border-width').value = calibration.border_width || 10; (document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
window.edgeSpans = { window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -312,7 +312,7 @@ export async function showCSSCalibration(cssId) {
// Show overlay toggle and check current status // Show overlay toggle and check current status
_overlayStartedHere = false; _overlayStartedHere = false;
const overlayBtn = document.getElementById('calibration-overlay-btn'); const overlayBtn = document.getElementById('calibration-overlay-btn') as HTMLElement;
overlayBtn.style.display = ''; overlayBtn.style.display = '';
_setOverlayBtnActive(false); _setOverlayBtnActive(false);
_checkOverlayStatus(cssId); _checkOverlayStatus(cssId);
@@ -332,7 +332,7 @@ export async function showCSSCalibration(cssId) {
} }
window._calibrationResizeObserver.observe(preview); window._calibrationResizeObserver.observe(preview);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load CSS calibration:', error); console.error('Failed to load CSS calibration:', error);
showToast(t('calibration.error.load_failed'), 'error'); showToast(t('calibration.error.load_failed'), 'error');
@@ -340,58 +340,58 @@ export async function showCSSCalibration(cssId) {
} }
export function updateOffsetSkipLock() { export function updateOffsetSkipLock() {
const offsetEl = document.getElementById('cal-offset'); const offsetEl = document.getElementById('cal-offset') as HTMLInputElement;
const skipStartEl = document.getElementById('cal-skip-start'); const skipStartEl = document.getElementById('cal-skip-start') as HTMLInputElement;
const skipEndEl = document.getElementById('cal-skip-end'); const skipEndEl = document.getElementById('cal-skip-end') as HTMLInputElement;
const hasOffset = parseInt(offsetEl.value || 0) > 0; const hasOffset = parseInt(offsetEl.value || '0') > 0;
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0; const hasSkip = parseInt(skipStartEl.value || '0') > 0 || parseInt(skipEndEl.value || '0') > 0;
skipStartEl.disabled = hasOffset; skipStartEl.disabled = hasOffset;
skipEndEl.disabled = hasOffset; skipEndEl.disabled = hasOffset;
offsetEl.disabled = hasSkip; offsetEl.disabled = hasSkip;
} }
export function updateCalibrationPreview() { export function updateCalibrationPreview() {
const total = parseInt(document.getElementById('cal-top-leds').value || 0) + const total = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0') +
parseInt(document.getElementById('cal-right-leds').value || 0) + parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0') +
parseInt(document.getElementById('cal-bottom-leds').value || 0) + parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0') +
parseInt(document.getElementById('cal-left-leds').value || 0); parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0');
const totalEl = document.querySelector('.preview-screen-total'); const totalEl = document.querySelector('.preview-screen-total') as HTMLElement;
const inCSS = _isCSS(); const inCSS = _isCSS();
const declaredCount = inCSS const declaredCount = inCSS
? parseInt(document.getElementById('cal-css-led-count').value || 0) ? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value || '0')
: parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); : parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent || '0');
if (inCSS) { if (inCSS) {
document.getElementById('cal-device-led-count-inline').textContent = declaredCount || '—'; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = String(declaredCount || '—');
} }
// In device mode: calibration total must exactly equal device LED count // In device mode: calibration total must exactly equal device LED count
// In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest) // In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest)
const mismatch = inCSS const mismatch = inCSS
? (declaredCount > 0 && total > declaredCount) ? (declaredCount > 0 && total > declaredCount)
: (total !== declaredCount); : (total !== declaredCount);
document.getElementById('cal-total-leds-inline').innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total; (document.getElementById('cal-total-leds-inline') as HTMLElement).innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total;
if (totalEl) totalEl.classList.toggle('mismatch', mismatch); if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
const startPos = document.getElementById('cal-start-position').value; const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => { ['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`); const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`) as HTMLElement;
if (cornerEl) { if (cornerEl) {
if (corner === startPos) cornerEl.classList.add('active'); if (corner === startPos) cornerEl.classList.add('active');
else cornerEl.classList.remove('active'); else cornerEl.classList.remove('active');
} }
}); });
const direction = document.getElementById('cal-layout').value; const direction = (document.getElementById('cal-layout') as HTMLSelectElement).value;
const dirIcon = document.getElementById('direction-icon'); const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label'); const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.innerHTML = direction === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW; if (dirIcon) dirIcon.innerHTML = direction === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
const stateKey = _isCSS() ? _cssStateKey() : deviceId; const stateKey = _isCSS() ? _cssStateKey() : deviceId;
const activeEdges = calibrationTestState[stateKey] || new Set(); const activeEdges = calibrationTestState[stateKey] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => { ['top', 'right', 'bottom', 'left'].forEach(edge => {
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement;
if (!toggleEl) return; if (!toggleEl) return;
if (activeEdges.has(edge)) { if (activeEdges.has(edge)) {
const [r, g, b] = EDGE_TEST_COLORS[edge]; const [r, g, b] = EDGE_TEST_COLORS[edge];
@@ -404,9 +404,9 @@ export function updateCalibrationPreview() {
}); });
['top', 'right', 'bottom', 'left'].forEach(edge => { ['top', 'right', 'bottom', 'left'].forEach(edge => {
const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; const count = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`); const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`) as HTMLElement;
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement;
if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0); if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0); if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
}); });
@@ -420,11 +420,11 @@ export function updateCalibrationPreview() {
} }
export function renderCalibrationCanvas() { export function renderCalibrationCanvas() {
const canvas = document.getElementById('calibration-preview-canvas'); const canvas = document.getElementById('calibration-preview-canvas') as HTMLCanvasElement;
if (!canvas) return; if (!canvas) return;
const container = canvas.parentElement; const container = canvas.parentElement;
const containerRect = container.getBoundingClientRect(); const containerRect = container!.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return; if (containerRect.width === 0 || containerRect.height === 0) return;
const padX = 40; const padX = 40;
@@ -435,7 +435,7 @@ export function renderCalibrationCanvas() {
const canvasH = containerRect.height + padY * 2; const canvasH = containerRect.height + padY * 2;
canvas.width = canvasW * dpr; canvas.width = canvasW * dpr;
canvas.height = canvasH * dpr; canvas.height = canvasH * dpr;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvasW, canvasH); ctx.clearRect(0, 0, canvasW, canvasH);
@@ -444,20 +444,20 @@ export function renderCalibrationCanvas() {
const cW = containerRect.width; const cW = containerRect.width;
const cH = containerRect.height; const cH = containerRect.height;
const startPos = document.getElementById('cal-start-position').value; const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
const layout = document.getElementById('cal-layout').value; const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value;
const offset = parseInt(document.getElementById('cal-offset').value || 0); const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0');
const calibration = { const calibration = {
start_position: startPos, start_position: startPos,
layout: layout, layout: layout,
offset: offset, offset: offset,
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0), leds_top: parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0'),
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0), leds_right: parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0'),
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0), leds_bottom: parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0'),
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0), leds_left: parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0'),
}; };
const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0); const skipStart = parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0');
const skipEnd = parseInt(document.getElementById('cal-skip-end').value || 0); const skipEnd = parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0');
const segments = buildSegments(calibration); const segments = buildSegments(calibration);
if (segments.length === 0) return; if (segments.length === 0) return;
@@ -477,7 +477,7 @@ export function renderCalibrationCanvas() {
const edgeLenH = cW - 2 * cw; const edgeLenH = cW - 2 * cw;
const edgeLenV = cH - 2 * ch; const edgeLenV = cH - 2 * ch;
const edgeGeometry = { const edgeGeometry: any = {
top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
@@ -485,7 +485,7 @@ export function renderCalibrationCanvas() {
}; };
const toggleSize = 16; const toggleSize = 16;
const axisPos = { const axisPos: any = {
top: oy - toggleSize - 3, top: oy - toggleSize - 3,
bottom: oy + cH + toggleSize + 3, bottom: oy + cH + toggleSize + 3,
left: ox - toggleSize - 3, left: ox - toggleSize - 3,
@@ -493,14 +493,14 @@ export function renderCalibrationCanvas() {
}; };
const arrowInset = 12; const arrowInset = 12;
const arrowPos = { const arrowPos: any = {
top: oy + ch + arrowInset, top: oy + ch + arrowInset,
bottom: oy + cH - ch - arrowInset, bottom: oy + cH - ch - arrowInset,
left: ox + cw + arrowInset, left: ox + cw + arrowInset,
right: ox + cW - cw - arrowInset, right: ox + cW - cw - arrowInset,
}; };
segments.forEach(seg => { segments.forEach((seg: any) => {
const geo = edgeGeometry[seg.edge]; const geo = edgeGeometry[seg.edge];
if (!geo) return; if (!geo) return;
@@ -510,23 +510,23 @@ export function renderCalibrationCanvas() {
const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start; const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start;
const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1; const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1;
const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart; const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart;
const toEdgeLabel = (i) => { const toEdgeLabel = (i: number) => {
if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
if (count <= 1) return edgeDisplayStart; if (count <= 1) return edgeDisplayStart;
return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange); return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange);
}; };
const edgeBounds = new Set(); const edgeBounds = new Set<number>();
edgeBounds.add(0); edgeBounds.add(0);
if (count > 1) edgeBounds.add(count - 1); if (count > 1) edgeBounds.add(count - 1);
const specialTicks = new Set(); const specialTicks = new Set<number>();
if (offset > 0 && totalLeds > 0) { if (offset > 0 && totalLeds > 0) {
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds; const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
if (zeroPos < count) specialTicks.add(zeroPos); if (zeroPos < count) specialTicks.add(zeroPos);
} }
const labelsToShow = new Set([...specialTicks]); const labelsToShow = new Set<number>([...specialTicks]);
if (count > 2) { if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1); const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
@@ -541,12 +541,12 @@ export function renderCalibrationCanvas() {
if (Math.floor(count / s) <= maxIntermediate) { step = s; break; } if (Math.floor(count / s) <= maxIntermediate) { step = s; break; }
} }
const tickPx = i => { const tickPx = (i: number) => {
const f = i / (count - 1); const f = i / (count - 1);
return (seg.reverse ? (1 - f) : f) * edgeLen; return (seg.reverse ? (1 - f) : f) * edgeLen;
}; };
const placed = []; const placed: number[] = [];
specialTicks.forEach(i => placed.push(tickPx(i))); specialTicks.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) { for (let i = 1; i < count - 1; i++) {
@@ -634,12 +634,12 @@ export function renderCalibrationCanvas() {
function updateSpanBars() { function updateSpanBars() {
const spans = window.edgeSpans || {}; const spans = window.edgeSpans || {};
const container = document.querySelector('.calibration-preview'); const container = document.querySelector('.calibration-preview') as HTMLElement;
['top', 'right', 'bottom', 'left'].forEach(edge => { ['top', 'right', 'bottom', 'left'].forEach(edge => {
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`); const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`) as HTMLElement;
if (!bar) return; if (!bar) return;
const span = spans[edge] || { start: 0, end: 1 }; const span = spans[edge] || { start: 0, end: 1 };
const edgeEl = bar.parentElement; const edgeEl = bar.parentElement as HTMLElement;
const isHorizontal = (edge === 'top' || edge === 'bottom'); const isHorizontal = (edge === 'top' || edge === 'bottom');
if (isHorizontal) { if (isHorizontal) {
@@ -653,7 +653,7 @@ function updateSpanBars() {
} }
if (!container) return; if (!container) return;
const toggle = container.querySelector(`.toggle-${edge}`); const toggle = container.querySelector(`.toggle-${edge}`) as HTMLElement;
if (!toggle) return; if (!toggle) return;
if (isHorizontal) { if (isHorizontal) {
const cornerW = 56; const cornerW = 56;
@@ -675,22 +675,22 @@ function initSpanDrag() {
const MIN_SPAN = 0.05; const MIN_SPAN = 0.05;
document.querySelectorAll('.edge-span-bar').forEach(bar => { document.querySelectorAll('.edge-span-bar').forEach(bar => {
const edge = bar.dataset.edge; const edge = (bar as HTMLElement).dataset.edge!;
const isHorizontal = (edge === 'top' || edge === 'bottom'); const isHorizontal = (edge === 'top' || edge === 'bottom');
bar.addEventListener('click', e => e.stopPropagation()); bar.addEventListener('click', (e: Event) => e.stopPropagation());
bar.querySelectorAll('.edge-span-handle').forEach(handle => { bar.querySelectorAll('.edge-span-handle').forEach(handle => {
handle.addEventListener('mousedown', e => { handle.addEventListener('mousedown', (e: Event) => {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
if (edgeLeds === 0) return; if (edgeLeds === 0) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const handleType = handle.dataset.handle; const handleType = (handle as HTMLElement).dataset.handle;
const edgeEl = bar.parentElement; const edgeEl = bar.parentElement as HTMLElement;
const rect = edgeEl.getBoundingClientRect(); const rect = edgeEl.getBoundingClientRect();
function onMouseMove(ev) { function onMouseMove(ev: MouseEvent) {
const span = window.edgeSpans[edge]; const span = window.edgeSpans[edge];
let fraction; let fraction;
if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width;
@@ -722,22 +722,22 @@ function initSpanDrag() {
}); });
}); });
bar.addEventListener('mousedown', e => { bar.addEventListener('mousedown', (e: Event) => {
if (e.target.classList.contains('edge-span-handle')) return; if ((e.target as HTMLElement).classList.contains('edge-span-handle')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const edgeEl = bar.parentElement; const edgeEl = bar.parentElement as HTMLElement;
const rect = edgeEl.getBoundingClientRect(); const rect = edgeEl.getBoundingClientRect();
const span = window.edgeSpans[edge]; const span = window.edgeSpans[edge];
const spanWidth = span.end - span.start; const spanWidth = span.end - span.start;
let startFraction; let startFraction;
if (isHorizontal) startFraction = (e.clientX - rect.left) / rect.width; if (isHorizontal) startFraction = ((e as MouseEvent).clientX - rect.left) / rect.width;
else startFraction = (e.clientY - rect.top) / rect.height; else startFraction = ((e as MouseEvent).clientY - rect.top) / rect.height;
const offsetInSpan = startFraction - span.start; const offsetInSpan = startFraction - span.start;
function onMouseMove(ev) { function onMouseMove(ev: MouseEvent) {
let fraction; let fraction;
if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width;
else fraction = (ev.clientY - rect.top) / rect.height; else fraction = (ev.clientY - rect.top) / rect.height;
@@ -772,8 +772,8 @@ function initSpanDrag() {
updateSpanBars(); updateSpanBars();
} }
export function setStartPosition(position) { export function setStartPosition(position: any) {
document.getElementById('cal-start-position').value = position; (document.getElementById('cal-start-position') as HTMLSelectElement).value = position;
updateCalibrationPreview(); updateCalibrationPreview();
} }
@@ -783,20 +783,20 @@ export function toggleEdgeInputs() {
} }
export function toggleDirection() { export function toggleDirection() {
const select = document.getElementById('cal-layout'); const select = document.getElementById('cal-layout') as HTMLSelectElement;
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise'; select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview(); updateCalibrationPreview();
} }
export async function toggleTestEdge(edge) { export async function toggleTestEdge(edge: any) {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0;
if (edgeLeds === 0) return; if (edgeLeds === 0) return;
const error = document.getElementById('calibration-error'); const error = document.getElementById('calibration-error') as HTMLElement;
if (_isCSS()) { if (_isCSS()) {
const cssId = document.getElementById('calibration-css-id').value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
const testDeviceId = document.getElementById('calibration-test-device')?.value; const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
if (!testDeviceId) return; if (!testDeviceId) return;
const stateKey = _cssStateKey(); const stateKey = _cssStateKey();
@@ -804,8 +804,8 @@ export async function toggleTestEdge(edge) {
if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge); if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge);
else calibrationTestState[stateKey].add(edge); else calibrationTestState[stateKey].add(edge);
const edges = {}; const edges: any = {};
calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; }); calibrationTestState[stateKey].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; });
updateCalibrationPreview(); updateCalibrationPreview();
try { try {
@@ -815,26 +815,28 @@ export async function toggleTestEdge(edge) {
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = t('calibration.error.test_toggle_failed'); const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err); console.error('Failed to toggle CSS test edge:', err);
error.textContent = t('calibration.error.test_toggle_failed'); error.textContent = err.message || t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
return; return;
} }
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set(); if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge); if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
else calibrationTestState[deviceId].add(edge); else calibrationTestState[deviceId].add(edge);
const edges = {}; const edges: any = {};
calibrationTestState[deviceId].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; }); calibrationTestState[deviceId].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; });
updateCalibrationPreview(); updateCalibrationPreview();
@@ -845,18 +847,20 @@ export async function toggleTestEdge(edge) {
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = t('calibration.error.test_toggle_failed'); const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle test edge:', err); console.error('Failed to toggle test edge:', err);
error.textContent = t('calibration.error.test_toggle_failed'); error.textContent = err.message || t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} }
async function clearTestMode(deviceId) { async function clearTestMode(deviceId: any) {
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) return; if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) return;
calibrationTestState[deviceId] = new Set(); calibrationTestState[deviceId] = new Set();
try { try {
@@ -872,9 +876,9 @@ async function clearTestMode(deviceId) {
export async function saveCalibration() { export async function saveCalibration() {
const cssMode = _isCSS(); const cssMode = _isCSS();
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
const cssId = document.getElementById('calibration-css-id').value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
const error = document.getElementById('calibration-error'); const error = document.getElementById('calibration-error') as HTMLElement;
if (cssMode) { if (cssMode) {
await _clearCSSTestMode(); await _clearCSSTestMode();
@@ -883,15 +887,15 @@ export async function saveCalibration() {
} }
updateCalibrationPreview(); updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0); const topLeds = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0');
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0); const rightLeds = parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0');
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0); const bottomLeds = parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0');
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); const leftLeds = parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0');
const total = topLeds + rightLeds + bottomLeds + leftLeds; const total = topLeds + rightLeds + bottomLeds + leftLeds;
const declaredLedCount = cssMode const declaredLedCount = cssMode
? parseInt(document.getElementById('cal-css-led-count').value) || 0 ? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value) || 0
: parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0; : parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent!) || 0;
if (!cssMode) { if (!cssMode) {
if (total !== declaredLedCount) { if (total !== declaredLedCount) {
error.textContent = t('calibration.error.led_count_mismatch'); error.textContent = t('calibration.error.led_count_mismatch');
@@ -906,9 +910,9 @@ export async function saveCalibration() {
} }
} }
const startPosition = document.getElementById('cal-start-position').value; const startPosition = (document.getElementById('cal-start-position') as HTMLSelectElement).value;
const layout = document.getElementById('cal-layout').value; const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value;
const offset = parseInt(document.getElementById('cal-offset').value || 0); const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0');
const spans = window.edgeSpans || {}; const spans = window.edgeSpans || {};
const calibration = { const calibration = {
@@ -919,9 +923,9 @@ export async function saveCalibration() {
span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1, span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1,
span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1, span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1,
span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1, span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1,
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0), skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0), skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
border_width: parseInt(document.getElementById('cal-border-width').value) || 10, border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
}; };
try { try {
@@ -948,19 +952,21 @@ export async function saveCalibration() {
} }
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = t('calibration.error.save_failed'); const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.save_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to save calibration:', err); console.error('Failed to save calibration:', err);
error.textContent = t('calibration.error.save_failed'); error.textContent = err.message || t('calibration.error.save_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} }
function getEdgeOrder(startPosition, layout) { function getEdgeOrder(startPosition: any, layout: any) {
const orders = { const orders: any = {
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'], 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'], 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
@@ -973,8 +979,8 @@ function getEdgeOrder(startPosition, layout) {
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom']; return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
} }
function shouldReverse(edge, startPosition, layout) { function shouldReverse(edge: any, startPosition: any, layout: any) {
const reverseRules = { const reverseRules: any = {
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true }, 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false }, 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false }, 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
@@ -988,19 +994,19 @@ function shouldReverse(edge, startPosition, layout) {
return rules ? rules[edge] : false; return rules ? rules[edge] : false;
} }
function buildSegments(calibration) { function buildSegments(calibration: any) {
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout); const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
const edgeCounts = { const edgeCounts: any = {
top: calibration.leds_top || 0, top: calibration.leds_top || 0,
right: calibration.leds_right || 0, right: calibration.leds_right || 0,
bottom: calibration.leds_bottom || 0, bottom: calibration.leds_bottom || 0,
left: calibration.leds_left || 0 left: calibration.leds_left || 0
}; };
const segments = []; const segments: any[] = [];
let ledStart = calibration.offset || 0; let ledStart = calibration.offset || 0;
edgeOrder.forEach(edge => { edgeOrder.forEach((edge: any) => {
const count = edgeCounts[edge]; const count = edgeCounts[edge];
if (count > 0) { if (count > 0) {
segments.push({ segments.push({

View File

@@ -5,17 +5,41 @@
* gradient stops state and renders into the CSS editor modal DOM. * gradient stops state and renders into the CSS editor modal DOM.
*/ */
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
/* ── Types ─────────────────────────────────────────────────────── */
interface GradientStop {
position: number;
color: number[];
colorRight: number[] | null;
}
interface GradientDragState {
idx: number;
trackRect: DOMRect;
}
interface GradientPresetStop {
position: number;
color: number[];
color_right?: number[];
}
interface CustomPreset {
name: string;
stops: GradientPresetStop[];
}
/* ── Color conversion utilities ───────────────────────────────── */ /* ── Color conversion utilities ───────────────────────────────── */
export function rgbArrayToHex(rgb) { export function rgbArrayToHex(rgb: number[]): string {
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
} }
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */ /** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
export function hexToRgbArray(hex) { export function hexToRgbArray(hex: string): number[] {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
} }
@@ -26,22 +50,22 @@ export function hexToRgbArray(hex) {
* Internal state: array of stop objects. * Internal state: array of stop objects.
* Each stop: { position: float 01, color: [R,G,B], colorRight: [R,G,B]|null } * Each stop: { position: float 01, color: [R,G,B], colorRight: [R,G,B]|null }
*/ */
let _gradientStops = []; let _gradientStops: GradientStop[] = [];
let _gradientSelectedIdx = -1; let _gradientSelectedIdx: number = -1;
let _gradientDragging = null; // { idx, trackRect } while dragging let _gradientDragging: GradientDragState | null = null;
let _gradientOnChange = null; let _gradientOnChange: (() => void) | null = null;
/** Set a callback that fires whenever stops change. */ /** Set a callback that fires whenever stops change. */
export function gradientSetOnChange(fn) { _gradientOnChange = fn; } export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
/** Read-only accessor for save/dirty-check from the parent module. */ /** Read-only accessor for save/dirty-check from the parent module. */
export function getGradientStops() { export function getGradientStops(): GradientStop[] {
return _gradientStops; return _gradientStops;
} }
/* ── Interpolation (mirrors Python backend exactly) ───────────── */ /* ── Interpolation (mirrors Python backend exactly) ───────────── */
function _gradientInterpolate(stops, pos) { function _gradientInterpolate(stops: GradientStop[], pos: number): number[] {
if (!stops.length) return [128, 128, 128]; if (!stops.length) return [128, 128, 128];
const sorted = [...stops].sort((a, b) => a.position - b.position); const sorted = [...stops].sort((a, b) => a.position - b.position);
@@ -66,9 +90,9 @@ function _gradientInterpolate(stops, pos) {
/* ── Init ─────────────────────────────────────────────────────── */ /* ── Init ─────────────────────────────────────────────────────── */
export function gradientInit(stops) { export function gradientInit(stops: GradientPresetStop[]): void {
_gradientStops = stops.map(s => ({ _gradientStops = stops.map(s => ({
position: parseFloat(s.position ?? 0), position: parseFloat(String(s.position ?? 0)),
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255], color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null, colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
})); }));
@@ -171,27 +195,27 @@ export const GRADIENT_PRESETS = {
/** /**
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}). * Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
*/ */
export function gradientPresetStripHTML(stops, w = 80, h = 16) { export function gradientPresetStripHTML(stops: GradientPresetStop[], w: number = 80, h: number = 16): string {
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`; return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
} }
export function applyGradientPreset(key) { export function applyGradientPreset(key: string): void {
if (!key || !GRADIENT_PRESETS[key]) return; if (!key || !GRADIENT_PRESETS[key]) return;
gradientInit(GRADIENT_PRESETS[key]); gradientInit(GRADIENT_PRESETS[key]);
} }
/* ── Render ───────────────────────────────────────────────────── */ /* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll() { export function gradientRenderAll(): void {
_gradientRenderCanvas(); _gradientRenderCanvas();
_gradientRenderMarkers(); _gradientRenderMarkers();
_gradientRenderStopList(); _gradientRenderStopList();
if (_gradientOnChange) _gradientOnChange(); if (_gradientOnChange) _gradientOnChange();
} }
function _gradientRenderCanvas() { function _gradientRenderCanvas(): void {
const canvas = document.getElementById('gradient-canvas'); const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null;
if (!canvas) return; if (!canvas) return;
// Sync canvas pixel width to its CSS display width // Sync canvas pixel width to its CSS display width
@@ -216,7 +240,7 @@ function _gradientRenderCanvas() {
ctx.putImageData(imgData, 0, 0); ctx.putImageData(imgData, 0, 0);
} }
function _gradientRenderMarkers() { function _gradientRenderMarkers(): void {
const track = document.getElementById('gradient-markers-track'); const track = document.getElementById('gradient-markers-track');
if (!track) return; if (!track) return;
track.innerHTML = ''; track.innerHTML = '';
@@ -245,13 +269,13 @@ function _gradientRenderMarkers() {
* Update the selected stop index and reflect it via CSS classes only * Update the selected stop index and reflect it via CSS classes only
* no DOM rebuild, so in-flight click events on child elements are preserved. * no DOM rebuild, so in-flight click events on child elements are preserved.
*/ */
function _gradientSelectStop(idx) { function _gradientSelectStop(idx: number): void {
_gradientSelectedIdx = idx; _gradientSelectedIdx = idx;
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx)); document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx)); document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
} }
function _gradientRenderStopList() { function _gradientRenderStopList(): void {
const list = document.getElementById('gradient-stops-list'); const list = document.getElementById('gradient-stops-list');
if (!list) return; if (!list) return;
list.innerHTML = ''; list.innerHTML = '';
@@ -283,8 +307,9 @@ function _gradientRenderStopList() {
// Position // Position
const posInput = row.querySelector('.gradient-stop-pos'); const posInput = row.querySelector('.gradient-stop-pos');
posInput.addEventListener('change', (e) => { posInput.addEventListener('change', (e) => {
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)); const target = e.target as HTMLInputElement;
e.target.value = val.toFixed(2); const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
target.value = val.toFixed(2);
_gradientStops[idx].position = val; _gradientStops[idx].position = val;
gradientRenderAll(); gradientRenderAll();
}); });
@@ -292,9 +317,10 @@ function _gradientRenderStopList() {
// Left color // Left color
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => { row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
_gradientStops[idx].color = hexToRgbArray(e.target.value); const val = (e.target as HTMLInputElement).value;
_gradientStops[idx].color = hexToRgbArray(val);
const markers = document.querySelectorAll('.gradient-marker'); const markers = document.querySelectorAll('.gradient-marker');
if (markers[idx]) markers[idx].style.background = e.target.value; if (markers[idx]) (markers[idx] as HTMLElement).style.background = val;
_gradientRenderCanvas(); _gradientRenderCanvas();
}); });
@@ -310,7 +336,7 @@ function _gradientRenderStopList() {
// Right color // Right color
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => { row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value); _gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value);
_gradientRenderCanvas(); _gradientRenderCanvas();
}); });
@@ -332,7 +358,7 @@ function _gradientRenderStopList() {
/* ── Add Stop ─────────────────────────────────────────────────── */ /* ── Add Stop ─────────────────────────────────────────────────── */
export function gradientAddStop(position) { export function gradientAddStop(position?: number): void {
if (position === undefined) { if (position === undefined) {
// Find the largest gap between adjacent stops and place in the middle // Find the largest gap between adjacent stops and place in the middle
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position); const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
@@ -355,12 +381,12 @@ export function gradientAddStop(position) {
/* ── Drag ─────────────────────────────────────────────────────── */ /* ── Drag ─────────────────────────────────────────────────────── */
function _gradientStartDrag(e, idx) { function _gradientStartDrag(e: MouseEvent, idx: number): void {
const track = document.getElementById('gradient-markers-track'); const track = document.getElementById('gradient-markers-track');
if (!track) return; if (!track) return;
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() }; _gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
const onMove = (me) => { const onMove = (me: MouseEvent): void => {
if (!_gradientDragging) return; if (!_gradientDragging) return;
const { trackRect } = _gradientDragging; const { trackRect } = _gradientDragging;
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width)); const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
@@ -368,7 +394,7 @@ function _gradientStartDrag(e, idx) {
gradientRenderAll(); gradientRenderAll();
}; };
const onUp = () => { const onUp = (): void => {
_gradientDragging = null; _gradientDragging = null;
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
@@ -383,7 +409,7 @@ function _gradientStartDrag(e, idx) {
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets'; const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
/** Load custom presets from localStorage. Returns an array of { name, stops }. */ /** Load custom presets from localStorage. Returns an array of { name, stops }. */
export function loadCustomGradientPresets() { export function loadCustomGradientPresets(): CustomPreset[] {
try { try {
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]'); return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
} catch { } catch {
@@ -392,7 +418,7 @@ export function loadCustomGradientPresets() {
} }
/** Save the current gradient stops as a named custom preset. */ /** Save the current gradient stops as a named custom preset. */
export function saveCurrentAsCustomPreset(name) { export function saveCurrentAsCustomPreset(name: string): void {
if (!name) return; if (!name) return;
const stops = _gradientStops.map(s => ({ const stops = _gradientStops.map(s => ({
position: s.position, position: s.position,
@@ -408,17 +434,17 @@ export function saveCurrentAsCustomPreset(name) {
} }
/** Delete a custom preset by name. */ /** Delete a custom preset by name. */
export function deleteCustomGradientPreset(name) { export function deleteCustomGradientPreset(name: string): void {
const presets = loadCustomGradientPresets().filter(p => p.name !== name); const presets = loadCustomGradientPresets().filter(p => p.name !== name);
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
} }
/* ── Track click → add stop ───────────────────────────────────── */ /* ── Track click → add stop ───────────────────────────────────── */
function _gradientSetupTrackClick() { function _gradientSetupTrackClick(): void {
const track = document.getElementById('gradient-markers-track'); const track = document.getElementById('gradient-markers-track');
if (!track || track._gradientClickBound) return; if (!track || (track as any)._gradientClickBound) return;
track._gradientClickBound = true; (track as any)._gradientClickBound = true;
track.addEventListener('click', (e) => { track.addEventListener('click', (e) => {
if (_gradientDragging) return; if (_gradientDragging) return;

View File

@@ -2,34 +2,38 @@
* Dashboard real-time target status overview. * Dashboard real-time target status overview.
*/ */
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.js'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.js'; } from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.js'; import { cardColorStyle } from '../core/card-colors.ts';
import { createFpsSparkline } from '../core/chart-utils.js'; import { createFpsSparkline } from '../core/chart-utils.ts';
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120; const MAX_FPS_SAMPLES = 120;
let _fpsHistory = {}; // { targetId: number[] } — fps_actual interface UptimeBase { seconds: number; timestamp: number; }
let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current interface MetricsRefs { fps: Element | null; errors: Element | null; row: Element | null; }
let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render
let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null;
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
let _metricsElements = new Map();
function _pushFps(targetId, actual, current) { let _fpsHistory: Record<string, number[]> = {};
let _fpsCurrentHistory: Record<string, number[]> = {};
let _fpsCharts: Record<string, any> = {};
let _lastRunningIds: string[] = [];
let _lastSyncClockIds: string = '';
let _uptimeBase: Record<string, UptimeBase> = {};
let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
let _uptimeElements: Record<string, Element> = {};
let _metricsElements: Map<string, MetricsRefs> = new Map();
function _pushFps(targetId: string, actual: number, current: number): void {
if (!_fpsHistory[targetId]) _fpsHistory[targetId] = []; if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
_fpsHistory[targetId].push(actual); _fpsHistory[targetId].push(actual);
if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift(); if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift();
@@ -39,18 +43,18 @@ function _pushFps(targetId, actual, current) {
if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift(); if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift();
} }
function _setUptimeBase(targetId, seconds) { function _setUptimeBase(targetId: string, seconds: number): void {
_uptimeBase[targetId] = { seconds, timestamp: Date.now() }; _uptimeBase[targetId] = { seconds, timestamp: Date.now() };
} }
function _getInterpolatedUptime(targetId) { function _getInterpolatedUptime(targetId: string): number | null {
const base = _uptimeBase[targetId]; const base = _uptimeBase[targetId];
if (!base) return null; if (!base) return null;
const elapsed = (Date.now() - base.timestamp) / 1000; const elapsed = (Date.now() - base.timestamp) / 1000;
return base.seconds + elapsed; return base.seconds + elapsed;
} }
function _cacheUptimeElements() { function _cacheUptimeElements(): void {
_uptimeElements = {}; _uptimeElements = {};
for (const id of _lastRunningIds) { for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`); const el = document.querySelector(`[data-uptime-text="${id}"]`);
@@ -58,7 +62,7 @@ function _cacheUptimeElements() {
} }
} }
function _startUptimeTimer() { function _startUptimeTimer(): void {
if (_uptimeTimer) return; if (_uptimeTimer) return;
_uptimeTimer = setInterval(() => { _uptimeTimer = setInterval(() => {
for (const id of _lastRunningIds) { for (const id of _lastRunningIds) {
@@ -72,7 +76,7 @@ function _startUptimeTimer() {
}, 1000); }, 1000);
} }
function _stopUptimeTimer() { function _stopUptimeTimer(): void {
if (_uptimeTimer) { if (_uptimeTimer) {
clearInterval(_uptimeTimer); clearInterval(_uptimeTimer);
_uptimeTimer = null; _uptimeTimer = null;
@@ -81,18 +85,18 @@ function _stopUptimeTimer() {
_uptimeElements = {}; _uptimeElements = {};
} }
function _destroyFpsCharts() { function _destroyFpsCharts(): void {
for (const id of Object.keys(_fpsCharts)) { for (const id of Object.keys(_fpsCharts)) {
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); } if (_fpsCharts[id]) { _fpsCharts[id].destroy(); }
} }
_fpsCharts = {}; _fpsCharts = {};
} }
function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { function _createFpsChart(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number): any {
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget); return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget);
} }
async function _initFpsCharts(runningTargetIds) { async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
_destroyFpsCharts(); _destroyFpsCharts();
// Seed FPS history from server ring buffer on first load // Seed FPS history from server ring buffer on first load
@@ -129,7 +133,7 @@ async function _initFpsCharts(runningTargetIds) {
_cacheMetricsElements(runningTargetIds); _cacheMetricsElements(runningTargetIds);
} }
function _cacheMetricsElements(runningIds) { function _cacheMetricsElements(runningIds: string[]): void {
_metricsElements.clear(); _metricsElements.clear();
for (const id of runningIds) { for (const id of runningIds) {
_metricsElements.set(id, { _metricsElements.set(id, {
@@ -141,7 +145,7 @@ function _cacheMetricsElements(runningIds) {
} }
/** Update running target metrics in-place (no HTML rebuild). */ /** Update running target metrics in-place (no HTML rebuild). */
function _updateRunningMetrics(enrichedRunning) { function _updateRunningMetrics(enrichedRunning: any[]): void {
for (const target of enrichedRunning) { for (const target of enrichedRunning) {
const state = target.state || {}; const state = target.state || {};
const metrics = target.metrics || {}; const metrics = target.metrics || {};
@@ -189,7 +193,7 @@ function _updateRunningMetrics(enrichedRunning) {
} }
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; errorsEl.title = String(errors); } if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
// Update health dot — prefer streaming reachability when processing // Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
@@ -211,7 +215,7 @@ function _updateRunningMetrics(enrichedRunning) {
} }
function _updateAutomationsInPlace(automations) { function _updateAutomationsInPlace(automations: Automation[]): void {
for (const a of automations) { for (const a of automations) {
const card = document.querySelector(`[data-automation-id="${a.id}"]`); const card = document.querySelector(`[data-automation-id="${a.id}"]`);
if (!card) continue; if (!card) continue;
@@ -237,7 +241,7 @@ function _updateAutomationsInPlace(automations) {
} }
} }
function _updateSyncClocksInPlace(syncClocks) { function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
for (const c of syncClocks) { for (const c of syncClocks) {
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`); const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
if (!card) continue; if (!card) continue;
@@ -252,7 +256,7 @@ function _updateSyncClocksInPlace(syncClocks) {
} }
} }
function renderDashboardSyncClock(clock) { function renderDashboardSyncClock(clock: SyncClock): string {
const toggleAction = clock.is_running const toggleAction = clock.is_running
? `dashboardPauseClock('${clock.id}')` ? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`; : `dashboardResumeClock('${clock.id}')`;
@@ -283,18 +287,18 @@ function renderDashboardSyncClock(clock) {
</div>`; </div>`;
} }
function _renderPollIntervalSelect() { function _renderPollIntervalSelect(): string {
const sec = Math.round(dashboardPollInterval / 1000); const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`; return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
} }
let _pollDebounce = null; let _pollDebounce: ReturnType<typeof setTimeout> | null = null;
export function changeDashboardPollInterval(value) { export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value'); const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`; if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce); clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => { _pollDebounce = setTimeout(() => {
const ms = parseInt(value, 10) * 1000; const ms = parseInt(String(value), 10) * 1000;
setDashboardPollInterval(ms); setDashboardPollInterval(ms);
startAutoRefresh(); startAutoRefresh();
stopPerfPolling(); stopPerfPolling();
@@ -302,12 +306,12 @@ export function changeDashboardPollInterval(value) {
}, 300); }, 300);
} }
function _getCollapsedSections() { function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
catch { return {}; } catch { return {}; }
} }
export function toggleDashboardSection(sectionKey) { export function toggleDashboardSection(sectionKey: string): void {
const collapsed = _getCollapsedSections(); const collapsed = _getCollapsedSections();
collapsed[sectionKey] = !collapsed[sectionKey]; collapsed[sectionKey] = !collapsed[sectionKey];
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed)); localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
@@ -316,37 +320,38 @@ export function toggleDashboardSection(sectionKey) {
const content = header.nextElementSibling; const content = header.nextElementSibling;
const chevron = header.querySelector('.dashboard-section-chevron'); const chevron = header.querySelector('.dashboard-section-chevron');
const nowCollapsed = collapsed[sectionKey]; const nowCollapsed = collapsed[sectionKey];
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)'; if (chevron) (chevron as HTMLElement).style.transform = nowCollapsed ? '' : 'rotate(90deg)';
// Animate collapse/expand unless reduced motion // Animate collapse/expand unless reduced motion
const contentEl = content as any;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
content.style.display = nowCollapsed ? 'none' : ''; contentEl.style.display = nowCollapsed ? 'none' : '';
return; return;
} }
if (content._dsAnim) { content._dsAnim.cancel(); content._dsAnim = null; } if (contentEl._dsAnim) { contentEl._dsAnim.cancel(); contentEl._dsAnim = null; }
if (nowCollapsed) { if (nowCollapsed) {
const h = content.offsetHeight; const h = contentEl.offsetHeight;
content.style.overflow = 'hidden'; contentEl.style.overflow = 'hidden';
const anim = content.animate( const anim = contentEl.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }], [{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 200, easing: 'ease-in-out' } { duration: 200, easing: 'ease-in-out' }
); );
content._dsAnim = anim; contentEl._dsAnim = anim;
anim.onfinish = () => { content.style.display = 'none'; content.style.overflow = ''; content._dsAnim = null; }; anim.onfinish = () => { contentEl.style.display = 'none'; contentEl.style.overflow = ''; contentEl._dsAnim = null; };
} else { } else {
content.style.display = ''; contentEl.style.display = '';
content.style.overflow = 'hidden'; contentEl.style.overflow = 'hidden';
const h = content.scrollHeight; const h = contentEl.scrollHeight;
const anim = content.animate( const anim = contentEl.animate(
[{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }], [{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
{ duration: 200, easing: 'ease-in-out' } { duration: 200, easing: 'ease-in-out' }
); );
content._dsAnim = anim; contentEl._dsAnim = anim;
anim.onfinish = () => { content.style.overflow = ''; content._dsAnim = null; }; anim.onfinish = () => { contentEl.style.overflow = ''; contentEl._dsAnim = null; };
} }
} }
function _sectionHeader(sectionKey, label, count, extraHtml = '') { function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = ''): string {
const collapsed = _getCollapsedSections(); const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey]; const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
@@ -358,13 +363,13 @@ function _sectionHeader(sectionKey, label, count, extraHtml = '') {
</div>`; </div>`;
} }
function _sectionContent(sectionKey, itemsHtml) { function _sectionContent(sectionKey: string, itemsHtml: string): string {
const collapsed = _getCollapsedSections(); const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey]; const isCollapsed = !!collapsed[sectionKey];
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`; return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
} }
export async function loadDashboard(forceFullRender = false) { export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
if (_dashboardLoading) return; if (_dashboardLoading) return;
set_dashboardLoading(true); set_dashboardLoading(true);
const container = document.getElementById('dashboard-content'); const container = document.getElementById('dashboard-content');
@@ -454,7 +459,7 @@ export async function loadDashboard(forceFullRender = false) {
// Scene Presets section // Scene Presets section
if (scenePresets.length > 0) { if (scenePresets.length > 0) {
const sceneSec = renderScenePresetsSection(scenePresets); const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec) { if (sceneSec && typeof sceneSec === 'object') {
dynamicHtml += `<div class="dashboard-section"> dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)} ${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionContent('scenes', sceneSec.content)} ${_sectionContent('scenes', sceneSec.content)}
@@ -536,7 +541,7 @@ export async function loadDashboard(forceFullRender = false) {
} }
} }
function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap = {}) { function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Record<string, Device> = {}, cssSourceMap: Record<string, ColorStripSource> = {}): string {
const state = target.state || {}; const state = target.state || {};
const metrics = target.metrics || {}; const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
@@ -582,7 +587,8 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
let healthDot = ''; let healthDot = '';
if (isLed && state.device_last_checked != null) { if (isLed && state.device_last_checked != null) {
const cls = state.device_online ? 'health-online' : 'health-offline'; const cls = state.device_online ? 'health-online' : 'health-offline';
healthDot = `<span class="health-dot ${cls}"></span>`; const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline');
healthDot = `<span class="health-dot ${cls}" role="status" aria-label="${statusLabel}"></span>`;
} }
const cStyle = cardColorStyle(target.id); const cStyle = cardColorStyle(target.id);
@@ -632,7 +638,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
} }
} }
function renderDashboardAutomation(automation, sceneMap = new Map()) { function renderDashboardAutomation(automation: Automation, sceneMap: Map<string, ScenePreset> = new Map()): string {
const isActive = automation.is_active; const isActive = automation.is_active;
const isDisabled = !automation.enabled; const isDisabled = !automation.enabled;
@@ -681,7 +687,7 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
</div>`; </div>`;
} }
export async function dashboardToggleAutomation(automationId, enable) { export async function dashboardToggleAutomation(automationId: string, enable: boolean): Promise<void> {
try { try {
const endpoint = enable ? 'enable' : 'disable'; const endpoint = enable ? 'enable' : 'disable';
const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, { const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, {
@@ -696,7 +702,7 @@ export async function dashboardToggleAutomation(automationId, enable) {
} }
} }
export async function dashboardStartTarget(targetId) { export async function dashboardStartTarget(targetId: string): Promise<void> {
try { try {
const response = await fetchWithAuth(`/output-targets/${targetId}/start`, { const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
method: 'POST', method: 'POST',
@@ -714,7 +720,7 @@ export async function dashboardStartTarget(targetId) {
} }
} }
export async function dashboardStopTarget(targetId) { export async function dashboardStopTarget(targetId: string): Promise<void> {
try { try {
const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, { const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
method: 'POST', method: 'POST',
@@ -732,7 +738,7 @@ export async function dashboardStopTarget(targetId) {
} }
} }
export async function dashboardStopAll() { export async function dashboardStopAll(): Promise<void> {
const confirmed = await showConfirm(t('confirm.stop_all')); const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return; if (!confirmed) return;
try { try {
@@ -753,7 +759,7 @@ export async function dashboardStopAll() {
} }
} }
export async function dashboardPauseClock(clockId) { export async function dashboardPauseClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' }); const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -765,7 +771,7 @@ export async function dashboardPauseClock(clockId) {
} }
} }
export async function dashboardResumeClock(clockId) { export async function dashboardResumeClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' }); const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -777,7 +783,7 @@ export async function dashboardResumeClock(clockId) {
} }
} }
export async function dashboardResetClock(clockId) { export async function dashboardResetClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' }); const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -789,17 +795,17 @@ export async function dashboardResetClock(clockId) {
} }
} }
export function stopUptimeTimer() { export function stopUptimeTimer(): void {
_stopUptimeTimer(); _stopUptimeTimer();
} }
// React to global server events when dashboard tab is active // React to global server events when dashboard tab is active
function _isDashboardActive() { function _isDashboardActive(): boolean {
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard'; return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
} }
let _eventDebounceTimer = null; let _eventDebounceTimer: ReturnType<typeof setTimeout> | null = null;
function _debouncedDashboardReload(forceFullRender = false) { function _debouncedDashboardReload(forceFullRender: boolean = false): void {
if (!_isDashboardActive()) return; if (!_isDashboardActive()) return;
clearTimeout(_eventDebounceTimer); clearTimeout(_eventDebounceTimer);
_eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300); _eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300);
@@ -810,8 +816,8 @@ document.addEventListener('server:automation_state_changed', () => _debouncedDas
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload()); document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']); const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
document.addEventListener('server:entity_changed', (e) => { document.addEventListener('server:entity_changed', (e: Event) => {
const { entity_type } = e.detail || {}; const { entity_type } = (e as CustomEvent).detail || {};
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true); if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
}); });

View File

@@ -6,36 +6,36 @@ import {
_discoveryScanRunning, set_discoveryScanRunning, _discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache,
} from '../core/state.js'; } from '../core/state.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.ts';
import { devicesCache } from '../core/state.js'; import { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.js'; import { showToast, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { _computeMaxFps, _renderFpsHint } from './devices.js'; import { _computeMaxFps, _renderFpsHint } from './devices.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.js'; import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.js'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
class AddDeviceModal extends Modal { class AddDeviceModal extends Modal {
constructor() { super('add-device-modal'); } constructor() { super('add-device-modal'); }
snapshotValues() { snapshotValues() {
return { return {
name: document.getElementById('device-name').value, name: (document.getElementById('device-name') as HTMLInputElement).value,
type: document.getElementById('device-type').value, type: (document.getElementById('device-type') as HTMLSelectElement).value,
url: document.getElementById('device-url').value, url: (document.getElementById('device-url') as HTMLInputElement).value,
serialPort: document.getElementById('device-serial-port').value, serialPort: (document.getElementById('device-serial-port') as HTMLSelectElement).value,
ledCount: document.getElementById('device-led-count').value, ledCount: (document.getElementById('device-led-count') as HTMLInputElement).value,
baudRate: document.getElementById('device-baud-rate').value, baudRate: (document.getElementById('device-baud-rate') as HTMLSelectElement).value,
ledType: document.getElementById('device-led-type')?.value || 'rgb', ledType: (document.getElementById('device-led-type') as HTMLSelectElement)?.value || 'rgb',
sendLatency: document.getElementById('device-send-latency')?.value || '0', sendLatency: (document.getElementById('device-send-latency') as HTMLInputElement)?.value || '0',
zones: JSON.stringify(_getCheckedZones('device-zone-list')), zones: JSON.stringify(_getCheckedZones('device-zone-list')),
zoneMode: _getZoneMode(), zoneMode: _getZoneMode(),
csptId: document.getElementById('device-css-processing-template')?.value || '', csptId: (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value || '',
dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet', dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0', dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1', dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1',
}; };
} }
} }
@@ -55,14 +55,14 @@ function _buildDeviceTypeItems() {
})); }));
} }
let _deviceTypeIconSelect = null; let _deviceTypeIconSelect: any = null;
let _csptEntitySelect = null; let _csptEntitySelect: any = null;
function _ensureDeviceTypeIconSelect() { function _ensureDeviceTypeIconSelect() {
const sel = document.getElementById('device-type'); const sel = document.getElementById('device-type');
if (!sel) return; if (!sel) return;
if (_deviceTypeIconSelect) { _deviceTypeIconSelect.updateItems(_buildDeviceTypeItems()); return; } if (_deviceTypeIconSelect) { _deviceTypeIconSelect.updateItems(_buildDeviceTypeItems()); return; }
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 }); _deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 } as any);
} }
function _ensureCsptEntitySelect() { function _ensureCsptEntitySelect() {
@@ -71,12 +71,12 @@ function _ensureCsptEntitySelect() {
const templates = csptCache.data || []; const templates = csptCache.data || [];
// Populate native <select> options // Populate native <select> options
sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` + sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join(''); templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_csptEntitySelect) _csptEntitySelect.destroy(); if (_csptEntitySelect) _csptEntitySelect.destroy();
if (templates.length > 0) { if (templates.length > 0) {
_csptEntitySelect = new EntitySelect({ _csptEntitySelect = new EntitySelect({
target: sel, target: sel,
getItems: () => (csptCache.data || []).map(tp => ({ getItems: () => (csptCache.data || []).map((tp: any) => ({
value: tp.id, value: tp.id,
label: tp.name, label: tp.name,
icon: ICON_TEMPLATE, icon: ICON_TEMPLATE,
@@ -85,7 +85,7 @@ function _ensureCsptEntitySelect() {
placeholder: t('palette.search'), placeholder: t('palette.search'),
allowNone: true, allowNone: true,
noneLabel: t('common.none_no_cspt'), noneLabel: t('common.none_no_cspt'),
}); } as any);
} }
} }
@@ -98,9 +98,9 @@ function _buildDmxProtocolItems() {
]; ];
} }
const _dmxProtocolIconSelects = {}; const _dmxProtocolIconSelects: Record<string, any> = {};
export function ensureDmxProtocolIconSelect(selectId) { export function ensureDmxProtocolIconSelect(selectId: any) {
const sel = document.getElementById(selectId); const sel = document.getElementById(selectId);
if (!sel) return; if (!sel) return;
if (_dmxProtocolIconSelects[selectId]) { if (_dmxProtocolIconSelects[selectId]) {
@@ -111,10 +111,10 @@ export function ensureDmxProtocolIconSelect(selectId) {
target: sel, target: sel,
items: _buildDmxProtocolItems(), items: _buildDmxProtocolItems(),
columns: 2, columns: 2,
}); } as any);
} }
export function destroyDmxProtocolIconSelect(selectId) { export function destroyDmxProtocolIconSelect(selectId: any) {
if (_dmxProtocolIconSelects[selectId]) { if (_dmxProtocolIconSelects[selectId]) {
_dmxProtocolIconSelects[selectId].destroy(); _dmxProtocolIconSelects[selectId].destroy();
delete _dmxProtocolIconSelects[selectId]; delete _dmxProtocolIconSelects[selectId];
@@ -133,9 +133,9 @@ function _buildSpiLedTypeItems() {
]; ];
} }
const _spiLedTypeIconSelects = {}; const _spiLedTypeIconSelects: Record<string, any> = {};
export function ensureSpiLedTypeIconSelect(selectId) { export function ensureSpiLedTypeIconSelect(selectId: any) {
const sel = document.getElementById(selectId); const sel = document.getElementById(selectId);
if (!sel) return; if (!sel) return;
if (_spiLedTypeIconSelects[selectId]) { if (_spiLedTypeIconSelects[selectId]) {
@@ -146,10 +146,10 @@ export function ensureSpiLedTypeIconSelect(selectId) {
target: sel, target: sel,
items: _buildSpiLedTypeItems(), items: _buildSpiLedTypeItems(),
columns: 3, columns: 3,
}); } as any);
} }
export function destroySpiLedTypeIconSelect(selectId) { export function destroySpiLedTypeIconSelect(selectId: any) {
if (_spiLedTypeIconSelects[selectId]) { if (_spiLedTypeIconSelects[selectId]) {
_spiLedTypeIconSelects[selectId].destroy(); _spiLedTypeIconSelects[selectId].destroy();
delete _spiLedTypeIconSelects[selectId]; delete _spiLedTypeIconSelects[selectId];
@@ -168,9 +168,9 @@ function _buildGameSenseDeviceTypeItems() {
]; ];
} }
const _gameSenseDeviceTypeIconSelects = {}; const _gameSenseDeviceTypeIconSelects: Record<string, any> = {};
export function ensureGameSenseDeviceTypeIconSelect(selectId) { export function ensureGameSenseDeviceTypeIconSelect(selectId: any) {
const sel = document.getElementById(selectId); const sel = document.getElementById(selectId);
if (!sel) return; if (!sel) return;
if (_gameSenseDeviceTypeIconSelects[selectId]) { if (_gameSenseDeviceTypeIconSelects[selectId]) {
@@ -181,10 +181,10 @@ export function ensureGameSenseDeviceTypeIconSelect(selectId) {
target: sel, target: sel,
items: _buildGameSenseDeviceTypeItems(), items: _buildGameSenseDeviceTypeItems(),
columns: 3, columns: 3,
}); } as any);
} }
export function destroyGameSenseDeviceTypeIconSelect(selectId) { export function destroyGameSenseDeviceTypeIconSelect(selectId: any) {
if (_gameSenseDeviceTypeIconSelects[selectId]) { if (_gameSenseDeviceTypeIconSelects[selectId]) {
_gameSenseDeviceTypeIconSelects[selectId].destroy(); _gameSenseDeviceTypeIconSelects[selectId].destroy();
delete _gameSenseDeviceTypeIconSelects[selectId]; delete _gameSenseDeviceTypeIconSelects[selectId];
@@ -192,31 +192,31 @@ export function destroyGameSenseDeviceTypeIconSelect(selectId) {
} }
export function onDeviceTypeChanged() { export function onDeviceTypeChanged() {
const deviceType = document.getElementById('device-type').value; const deviceType = (document.getElementById('device-type') as HTMLSelectElement).value;
if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType); if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType);
const urlGroup = document.getElementById('device-url-group'); const urlGroup = document.getElementById('device-url-group') as HTMLElement;
const urlInput = document.getElementById('device-url'); const urlInput = document.getElementById('device-url') as HTMLInputElement;
const serialGroup = document.getElementById('device-serial-port-group'); const serialGroup = document.getElementById('device-serial-port-group') as HTMLElement;
const serialSelect = document.getElementById('device-serial-port'); const serialSelect = document.getElementById('device-serial-port') as HTMLSelectElement;
const ledCountGroup = document.getElementById('device-led-count-group'); const ledCountGroup = document.getElementById('device-led-count-group') as HTMLElement;
const discoverySection = document.getElementById('discovery-section'); const discoverySection = document.getElementById('discovery-section') as HTMLElement;
const baudRateGroup = document.getElementById('device-baud-rate-group'); const baudRateGroup = document.getElementById('device-baud-rate-group') as HTMLElement;
const ledTypeGroup = document.getElementById('device-led-type-group'); const ledTypeGroup = document.getElementById('device-led-type-group') as HTMLElement;
const sendLatencyGroup = document.getElementById('device-send-latency-group'); const sendLatencyGroup = document.getElementById('device-send-latency-group') as HTMLElement;
// URL label / hint / placeholder — adapt per device type // URL label / hint / placeholder — adapt per device type
const urlLabel = document.getElementById('device-url-label'); const urlLabel = document.getElementById('device-url-label') as HTMLElement;
const urlHint = document.getElementById('device-url-hint'); const urlHint = document.getElementById('device-url-hint') as HTMLElement;
const zoneGroup = document.getElementById('device-zone-group'); const zoneGroup = document.getElementById('device-zone-group') as HTMLElement;
const scanBtn = document.getElementById('scan-network-btn'); const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group'); const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group') as HTMLElement;
const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group'); const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group') as HTMLElement;
const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group'); const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group') as HTMLElement;
// Hide zone group + mode group by default (shown only for openrgb) // Hide zone group + mode group by default (shown only for openrgb)
if (zoneGroup) zoneGroup.style.display = 'none'; if (zoneGroup) zoneGroup.style.display = 'none';
const zoneModeGroup = document.getElementById('device-zone-mode-group'); const zoneModeGroup = document.getElementById('device-zone-mode-group') as HTMLElement;
if (zoneModeGroup) zoneModeGroup.style.display = 'none'; if (zoneModeGroup) zoneModeGroup.style.display = 'none';
// Hide DMX fields by default // Hide DMX fields by default
@@ -482,15 +482,15 @@ export function onDeviceTypeChanged() {
} }
export function updateBaudFpsHint() { export function updateBaudFpsHint() {
const hintEl = document.getElementById('baud-fps-hint'); const hintEl = document.getElementById('baud-fps-hint') as HTMLElement;
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10); const baudRate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement).value, 10);
const ledCount = parseInt(document.getElementById('device-led-count').value, 10); const ledCount = parseInt((document.getElementById('device-led-count') as HTMLInputElement).value, 10);
const deviceType = document.getElementById('device-type')?.value || 'adalight'; const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'adalight';
_renderFpsHint(hintEl, baudRate, ledCount, deviceType); _renderFpsHint(hintEl, baudRate, ledCount, deviceType);
} }
function _renderDiscoveryList() { function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value; const selectedType = (document.getElementById('device-type') as HTMLSelectElement).value;
const devices = _discoveryCache[selectedType]; const devices = _discoveryCache[selectedType];
// Serial devices: populate serial port dropdown instead of discovery list // Serial devices: populate serial port dropdown instead of discovery list
@@ -500,9 +500,9 @@ function _renderDiscoveryList() {
} }
// WLED and others: render discovery list cards // WLED and others: render discovery list cards
const list = document.getElementById('discovery-list'); const list = document.getElementById('discovery-list') as HTMLElement;
const empty = document.getElementById('discovery-empty'); const empty = document.getElementById('discovery-empty') as HTMLElement;
const section = document.getElementById('discovery-section'); const section = document.getElementById('discovery-section') as HTMLElement;
if (!list || !section) return; if (!list || !section) return;
list.innerHTML = ''; list.innerHTML = '';
@@ -520,7 +520,7 @@ function _renderDiscoveryList() {
} }
empty.style.display = 'none'; empty.style.display = 'none';
devices.forEach(device => { devices.forEach((device: any) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : ''); card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
const meta = [device.ip]; const meta = [device.ip];
@@ -542,8 +542,8 @@ function _renderDiscoveryList() {
}); });
} }
function _populateSerialPortDropdown(devices) { function _populateSerialPortDropdown(devices: any) {
const select = document.getElementById('device-serial-port'); const select = document.getElementById('device-serial-port') as HTMLSelectElement;
select.innerHTML = ''; select.innerHTML = '';
if (devices.length === 0) { if (devices.length === 0) {
@@ -563,7 +563,7 @@ function _populateSerialPortDropdown(devices) {
placeholder.selected = true; placeholder.selected = true;
select.appendChild(placeholder); select.appendChild(placeholder);
devices.forEach(device => { devices.forEach((device: any) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = device.url; opt.value = device.url;
opt.textContent = device.name; opt.textContent = device.name;
@@ -576,89 +576,89 @@ function _populateSerialPortDropdown(devices) {
export function onSerialPortFocus() { export function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown // Lazy-load: trigger discovery when user opens the serial port dropdown
const deviceType = document.getElementById('device-type')?.value || 'adalight'; const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'adalight';
if (!(deviceType in _discoveryCache)) { if (!(deviceType in _discoveryCache)) {
scanForDevices(deviceType); scanForDevices(deviceType);
} }
} }
export function showAddDevice(presetType = null, cloneData = null) { export function showAddDevice(presetType: any = null, cloneData: any = null) {
// When no type specified: show type picker first // When no type specified: show type picker first
if (!presetType) { if (!presetType) {
showTypePicker({ showTypePicker({
title: t('device.select_type'), title: t('device.select_type'),
items: _buildDeviceTypeItems(), items: _buildDeviceTypeItems(),
onPick: (type) => showAddDevice(type), onPick: (type: any) => showAddDevice(type),
}); });
return; return;
} }
const form = document.getElementById('add-device-form'); const form = document.getElementById('add-device-form') as HTMLFormElement;
const error = document.getElementById('add-device-error'); const error = document.getElementById('add-device-error') as HTMLElement;
form.reset(); form.reset();
error.style.display = 'none'; error.style.display = 'none';
set_discoveryCache({}); set_discoveryCache({});
// Reset discovery section // Reset discovery section
const section = document.getElementById('discovery-section'); const section = document.getElementById('discovery-section') as HTMLElement;
if (section) { if (section) {
section.style.display = 'none'; section.style.display = 'none';
document.getElementById('discovery-list').innerHTML = ''; (document.getElementById('discovery-list') as HTMLElement).innerHTML = '';
document.getElementById('discovery-empty').style.display = 'none'; (document.getElementById('discovery-empty') as HTMLElement).style.display = 'none';
document.getElementById('discovery-loading').style.display = 'none'; (document.getElementById('discovery-loading') as HTMLElement).style.display = 'none';
} }
// Reset serial port dropdown // Reset serial port dropdown
document.getElementById('device-serial-port').innerHTML = ''; (document.getElementById('device-serial-port') as HTMLSelectElement).innerHTML = '';
const scanBtn = document.getElementById('scan-network-btn'); const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
_ensureDeviceTypeIconSelect(); _ensureDeviceTypeIconSelect();
// Populate CSPT template selector // Populate CSPT template selector
csptCache.fetch().then(() => _ensureCsptEntitySelect()); csptCache.fetch().then(() => _ensureCsptEntitySelect());
// Pre-select type and hide the type selector (already chosen) // Pre-select type and hide the type selector (already chosen)
document.getElementById('device-type').value = presetType; (document.getElementById('device-type') as HTMLSelectElement).value = presetType;
document.getElementById('device-type-group').style.display = 'none'; (document.getElementById('device-type-group') as HTMLElement).style.display = 'none';
const typeIcon = getDeviceTypeIcon(presetType); const typeIcon = getDeviceTypeIcon(presetType);
const typeName = t(`device.type.${presetType}`); const typeName = t(`device.type.${presetType}`);
document.getElementById('add-device-modal-title').innerHTML = `${typeIcon} ${t('devices.add')}: ${typeName}`; (document.getElementById('add-device-modal-title') as HTMLElement).innerHTML = `${typeIcon} ${t('devices.add')}: ${typeName}`;
addDeviceModal.open(); addDeviceModal.open();
onDeviceTypeChanged(); onDeviceTypeChanged();
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields) // Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
if (cloneData) { if (cloneData) {
document.getElementById('device-name').value = (cloneData.name || '') + ' (Copy)'; (document.getElementById('device-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
// Clear URL — devices must have unique addresses, user must enter a new one // Clear URL — devices must have unique addresses, user must enter a new one
const urlInput = document.getElementById('device-url'); const urlInput = document.getElementById('device-url') as HTMLInputElement;
if (urlInput) urlInput.value = ''; if (urlInput) urlInput.value = '';
// Prefill LED count // Prefill LED count
const ledCountInput = document.getElementById('device-led-count'); const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count; if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
// Prefill baud rate for serial devices // Prefill baud rate for serial devices
if (isSerialDevice(presetType)) { if (isSerialDevice(presetType)) {
const baudSelect = document.getElementById('device-baud-rate'); const baudSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate); if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
} }
// Prefill mock device fields // Prefill mock device fields
if (isMockDevice(presetType)) { if (isMockDevice(presetType)) {
const ledTypeEl = document.getElementById('device-led-type'); const ledTypeEl = document.getElementById('device-led-type') as HTMLSelectElement;
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb'; if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
const sendLatencyEl = document.getElementById('device-send-latency'); const sendLatencyEl = document.getElementById('device-send-latency') as HTMLInputElement;
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0; if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
} }
// Prefill DMX fields // Prefill DMX fields
if (isDmxDevice(presetType)) { if (isDmxDevice(presetType)) {
const dmxProto = document.getElementById('device-dmx-protocol'); const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol; if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
const dmxUniverse = document.getElementById('device-dmx-start-universe'); const dmxUniverse = document.getElementById('device-dmx-start-universe') as HTMLInputElement;
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe; if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
const dmxChannel = document.getElementById('device-dmx-start-channel'); const dmxChannel = document.getElementById('device-dmx-start-channel') as HTMLInputElement;
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel; if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
} }
// Prefill CSPT template selector (after fetch completes) // Prefill CSPT template selector (after fetch completes)
if (cloneData.default_css_processing_template_id) { if (cloneData.default_css_processing_template_id) {
csptCache.fetch().then(() => { csptCache.fetch().then(() => {
_ensureCsptEntitySelect(); _ensureCsptEntitySelect();
const csptEl = document.getElementById('device-css-processing-template'); const csptEl = document.getElementById('device-css-processing-template') as HTMLSelectElement;
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id; if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
}); });
} }
@@ -674,22 +674,22 @@ export async function closeAddDeviceModal() {
await addDeviceModal.close(); await addDeviceModal.close();
} }
export async function scanForDevices(forceType) { export async function scanForDevices(forceType?: any) {
const scanType = forceType || document.getElementById('device-type')?.value || 'wled'; const scanType = forceType || (document.getElementById('device-type') as HTMLSelectElement)?.value || 'wled';
// Per-type guard: prevent duplicate scans for the same type // Per-type guard: prevent duplicate scans for the same type
if (_discoveryScanRunning === scanType) return; if (_discoveryScanRunning === scanType) return;
set_discoveryScanRunning(scanType); set_discoveryScanRunning(scanType);
const loading = document.getElementById('discovery-loading'); const loading = document.getElementById('discovery-loading') as HTMLElement;
const list = document.getElementById('discovery-list'); const list = document.getElementById('discovery-list') as HTMLElement;
const empty = document.getElementById('discovery-empty'); const empty = document.getElementById('discovery-empty') as HTMLElement;
const section = document.getElementById('discovery-section'); const section = document.getElementById('discovery-section') as HTMLElement;
const scanBtn = document.getElementById('scan-network-btn'); const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
if (isSerialDevice(scanType)) { if (isSerialDevice(scanType)) {
// Show loading in the serial port dropdown // Show loading in the serial port dropdown
const select = document.getElementById('device-serial-port'); const select = document.getElementById('device-serial-port') as HTMLSelectElement;
select.innerHTML = ''; select.innerHTML = '';
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ''; opt.value = '';
@@ -714,7 +714,7 @@ export async function scanForDevices(forceType) {
if (!response.ok) { if (!response.ok) {
if (!isSerialDevice(scanType)) { if (!isSerialDevice(scanType)) {
empty.style.display = 'block'; empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error'); (empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
} }
return; return;
} }
@@ -723,17 +723,17 @@ export async function scanForDevices(forceType) {
_discoveryCache[scanType] = data.devices || []; _discoveryCache[scanType] = data.devices || [];
// Only render if the user is still on this type // Only render if the user is still on this type
const currentType = document.getElementById('device-type')?.value; const currentType = (document.getElementById('device-type') as HTMLSelectElement)?.value;
if (currentType === scanType) { if (currentType === scanType) {
_renderDiscoveryList(); _renderDiscoveryList();
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
loading.style.display = 'none'; loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
if (!isSerialDevice(scanType)) { if (!isSerialDevice(scanType)) {
empty.style.display = 'block'; empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error'); (empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
} }
console.error('Device scan failed:', err); console.error('Device scan failed:', err);
} finally { } finally {
@@ -743,15 +743,15 @@ export async function scanForDevices(forceType) {
} }
} }
export function selectDiscoveredDevice(device) { export function selectDiscoveredDevice(device: any) {
document.getElementById('device-name').value = device.name; (document.getElementById('device-name') as HTMLInputElement).value = device.name;
const typeSelect = document.getElementById('device-type'); const typeSelect = document.getElementById('device-type') as HTMLSelectElement;
if (typeSelect) typeSelect.value = device.device_type; if (typeSelect) typeSelect.value = device.device_type;
onDeviceTypeChanged(); onDeviceTypeChanged();
if (isSerialDevice(device.device_type)) { if (isSerialDevice(device.device_type)) {
document.getElementById('device-serial-port').value = device.url; (document.getElementById('device-serial-port') as HTMLSelectElement).value = device.url;
} else { } else {
document.getElementById('device-url').value = device.url; (document.getElementById('device-url') as HTMLInputElement).value = device.url;
} }
// Fetch zones for OpenRGB devices // Fetch zones for OpenRGB devices
if (isOpenrgbDevice(device.device_type)) { if (isOpenrgbDevice(device.device_type)) {
@@ -760,12 +760,12 @@ export function selectDiscoveredDevice(device) {
showToast(t('device.scan.selected'), 'info'); showToast(t('device.scan.selected'), 'info');
} }
export async function handleAddDevice(event) { export async function handleAddDevice(event: any) {
event.preventDefault(); event.preventDefault();
const name = document.getElementById('device-name').value.trim(); const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled'; const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'wled';
const error = document.getElementById('add-device-error'); const error = document.getElementById('add-device-error') as HTMLElement;
let url; let url;
if (isMockDevice(deviceType)) { if (isMockDevice(deviceType)) {
@@ -773,14 +773,14 @@ export async function handleAddDevice(event) {
} else if (isWsDevice(deviceType)) { } else if (isWsDevice(deviceType)) {
url = 'ws://'; url = 'ws://';
} else if (isSerialDevice(deviceType) || isEspnowDevice(deviceType)) { } else if (isSerialDevice(deviceType) || isEspnowDevice(deviceType)) {
url = document.getElementById('device-serial-port').value; url = (document.getElementById('device-serial-port') as HTMLSelectElement).value;
} else if (isChromaDevice(deviceType)) { } else if (isChromaDevice(deviceType)) {
const chromaType = document.getElementById('device-chroma-device-type')?.value || 'chromalink'; const chromaType = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
url = `chroma://${chromaType}`; url = `chroma://${chromaType}`;
} else if (isGameSenseDevice(deviceType)) { } else if (isGameSenseDevice(deviceType)) {
url = 'gamesense://auto'; url = 'gamesense://auto';
} else { } else {
url = document.getElementById('device-url').value.trim(); url = (document.getElementById('device-url') as HTMLInputElement).value.trim();
} }
// MQTT: ensure mqtt:// prefix // MQTT: ensure mqtt:// prefix
@@ -803,50 +803,50 @@ export async function handleAddDevice(event) {
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
try { try {
const body = { name, url, device_type: deviceType }; const body: any = { name, url, device_type: deviceType };
const ledCountInput = document.getElementById('device-led-count'); const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
if (ledCountInput && ledCountInput.value) { if (ledCountInput && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10); body.led_count = parseInt(ledCountInput.value, 10);
} }
const baudRateSelect = document.getElementById('device-baud-rate'); const baudRateSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) { if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
body.baud_rate = parseInt(baudRateSelect.value, 10); body.baud_rate = parseInt(baudRateSelect.value, 10);
} }
if (isMockDevice(deviceType)) { if (isMockDevice(deviceType)) {
const sendLatency = document.getElementById('device-send-latency')?.value; const sendLatency = (document.getElementById('device-send-latency') as HTMLInputElement)?.value;
if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10); if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10);
const ledType = document.getElementById('device-led-type')?.value; const ledType = (document.getElementById('device-led-type') as HTMLSelectElement)?.value;
body.rgbw = ledType === 'rgbw'; body.rgbw = ledType === 'rgbw';
} }
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) { if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
body.zone_mode = _getZoneMode(); body.zone_mode = _getZoneMode();
} }
if (isDmxDevice(deviceType)) { if (isDmxDevice(deviceType)) {
body.dmx_protocol = document.getElementById('device-dmx-protocol')?.value || 'artnet'; body.dmx_protocol = (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet';
body.dmx_start_universe = parseInt(document.getElementById('device-dmx-start-universe')?.value || '0', 10); body.dmx_start_universe = parseInt((document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0', 10);
body.dmx_start_channel = parseInt(document.getElementById('device-dmx-start-channel')?.value || '1', 10); body.dmx_start_channel = parseInt((document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1', 10);
} }
if (isEspnowDevice(deviceType)) { if (isEspnowDevice(deviceType)) {
body.espnow_peer_mac = document.getElementById('device-espnow-peer-mac')?.value || ''; body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || '';
body.espnow_channel = parseInt(document.getElementById('device-espnow-channel')?.value || '1', 10); body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10);
body.baud_rate = parseInt(document.getElementById('device-baud-rate')?.value || '921600', 10); body.baud_rate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement)?.value || '921600', 10);
} }
if (isHueDevice(deviceType)) { if (isHueDevice(deviceType)) {
body.hue_username = document.getElementById('device-hue-username')?.value || ''; body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
body.hue_client_key = document.getElementById('device-hue-client-key')?.value || ''; body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
body.hue_entertainment_group_id = document.getElementById('device-hue-group-id')?.value || ''; body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
} }
if (isSpiDevice(deviceType)) { if (isSpiDevice(deviceType)) {
body.spi_speed_hz = parseInt(document.getElementById('device-spi-speed')?.value || '800000', 10); body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
body.spi_led_type = document.getElementById('device-spi-led-type')?.value || 'WS2812B'; body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
} }
if (isChromaDevice(deviceType)) { if (isChromaDevice(deviceType)) {
body.chroma_device_type = document.getElementById('device-chroma-device-type')?.value || 'chromalink'; body.chroma_device_type = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
} }
if (isGameSenseDevice(deviceType)) { if (isGameSenseDevice(deviceType)) {
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard'; body.gamesense_device_type = (document.getElementById('device-gamesense-device-type') as HTMLSelectElement)?.value || 'keyboard';
} }
const csptId = document.getElementById('device-css-processing-template')?.value; const csptId = (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value;
if (csptId) body.default_css_processing_template_id = csptId; if (csptId) body.default_css_processing_template_id = csptId;
if (lastTemplateId) body.capture_template_id = lastTemplateId; if (lastTemplateId) body.capture_template_id = lastTemplateId;
@@ -871,13 +871,15 @@ export async function handleAddDevice(event) {
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error('Failed to add device:', errorData); console.error('Failed to add device:', errorData);
error.textContent = t('device_discovery.error.add_failed'); const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('device_discovery.error.add_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to add device:', err); console.error('Failed to add device:', err);
showToast(t('device_discovery.error.add_failed'), 'error'); showToast(err.message || t('device_discovery.error.add_failed'), 'error');
} }
} }
@@ -889,8 +891,8 @@ export async function handleAddDevice(event) {
* @param {string} containerId - ID of the zone checkbox list container * @param {string} containerId - ID of the zone checkbox list container
* @param {string[]} [preChecked=[]] - Zone names to pre-check * @param {string[]} [preChecked=[]] - Zone names to pre-check
*/ */
export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) { export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChecked: any = []) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId) as HTMLElement;
if (!container) return; if (!container) return;
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`; container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
@@ -904,18 +906,18 @@ export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = [])
} }
const data = await resp.json(); const data = await resp.json();
_renderZoneCheckboxes(container, data.zones, preChecked); _renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`; container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
} }
} }
function _renderZoneCheckboxes(container, zones, preChecked = []) { function _renderZoneCheckboxes(container: any, zones: any, preChecked: any = []) {
container.innerHTML = ''; container.innerHTML = '';
container._zonesData = zones; container._zonesData = zones;
const preSet = new Set(preChecked.map(n => n.toLowerCase())); const preSet = new Set(preChecked.map((n: any) => n.toLowerCase()));
zones.forEach(zone => { zones.forEach((zone: any) => {
const label = document.createElement('label'); const label = document.createElement('label');
label.className = 'zone-checkbox-item'; label.className = 'zone-checkbox-item';
@@ -941,44 +943,44 @@ function _renderZoneCheckboxes(container, zones, preChecked = []) {
_updateZoneModeVisibility(container.id); _updateZoneModeVisibility(container.id);
} }
export function _getCheckedZones(containerId) { export function _getCheckedZones(containerId: any) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return []; if (!container) return [];
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value); .map(cb => (cb as HTMLInputElement).value);
} }
/** /**
* Split an OpenRGB URL into base URL (without zones) and zone names. * Split an OpenRGB URL into base URL (without zones) and zone names.
* E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] } * E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] }
*/ */
export function _splitOpenrgbZone(url) { export function _splitOpenrgbZone(url: any) {
if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] }; if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] };
const stripped = url.slice('openrgb://'.length); const stripped = url.slice('openrgb://'.length);
const parts = stripped.split('/'); const parts = stripped.split('/');
// parts: [host:port, device_index, ...zone_str] // parts: [host:port, device_index, ...zone_str]
if (parts.length >= 3) { if (parts.length >= 3) {
const zoneStr = parts.slice(2).join('/'); const zoneStr = parts.slice(2).join('/');
const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean); const zones = zoneStr.split('+').map((z: any) => z.trim()).filter(Boolean);
const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1]; const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1];
return { baseUrl, zones }; return { baseUrl, zones };
} }
return { baseUrl: url, zones: [] }; return { baseUrl: url, zones: [] };
} }
function _appendZonesToUrl(baseUrl, zones) { function _appendZonesToUrl(baseUrl: any, zones: any) {
// Strip any existing zone suffix // Strip any existing zone suffix
const { baseUrl: clean } = _splitOpenrgbZone(baseUrl); const { baseUrl: clean } = _splitOpenrgbZone(baseUrl);
return clean + '/' + zones.join('+'); return clean + '/' + zones.join('+');
} }
/** Show/hide zone mode toggle based on how many zones are checked. */ /** Show/hide zone mode toggle based on how many zones are checked. */
export function _updateZoneModeVisibility(containerId) { export function _updateZoneModeVisibility(containerId: any) {
const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group' const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group'
: containerId === 'settings-zone-list' ? 'settings-zone-mode-group' : containerId === 'settings-zone-list' ? 'settings-zone-mode-group'
: null; : null;
if (!modeGroupId) return; if (!modeGroupId) return;
const modeGroup = document.getElementById(modeGroupId); const modeGroup = document.getElementById(modeGroupId) as HTMLElement;
if (!modeGroup) return; if (!modeGroup) return;
const checkedCount = _getCheckedZones(containerId).length; const checkedCount = _getCheckedZones(containerId).length;
modeGroup.style.display = checkedCount >= 2 ? '' : 'none'; modeGroup.style.display = checkedCount >= 2 ? '' : 'none';
@@ -986,55 +988,55 @@ export function _updateZoneModeVisibility(containerId) {
/** Get the selected zone mode radio value ('combined' or 'separate'). */ /** Get the selected zone mode radio value ('combined' or 'separate'). */
export function _getZoneMode(radioName = 'device-zone-mode') { export function _getZoneMode(radioName = 'device-zone-mode') {
const radio = document.querySelector(`input[name="${radioName}"]:checked`); const radio = document.querySelector(`input[name="${radioName}"]:checked`) as HTMLInputElement;
return radio ? radio.value : 'combined'; return radio ? radio.value : 'combined';
} }
/* ── New device type field visibility helpers ──────────────────── */ /* ── New device type field visibility helpers ──────────────────── */
function _showEspnowFields(show) { function _showEspnowFields(show: boolean) {
const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group']; const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}); });
} }
function _showHueFields(show) { function _showHueFields(show: boolean) {
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group']; const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}); });
} }
function _showSpiFields(show) { function _showSpiFields(show: boolean) {
const ids = ['device-spi-speed-group', 'device-spi-led-type-group']; const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}); });
} }
function _showChromaFields(show) { function _showChromaFields(show: boolean) {
const el = document.getElementById('device-chroma-device-type-group'); const el = document.getElementById('device-chroma-device-type-group') as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
} }
function _showGameSenseFields(show) { function _showGameSenseFields(show: boolean) {
const el = document.getElementById('device-gamesense-device-type-group'); const el = document.getElementById('device-gamesense-device-type-group') as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
} }
/* ── Clone device ──────────────────────────────────────────────── */ /* ── Clone device ──────────────────────────────────────────────── */
export async function cloneDevice(deviceId) { export async function cloneDevice(deviceId: any) {
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}`); const resp = await fetchWithAuth(`/devices/${deviceId}`);
if (!resp.ok) throw new Error('Failed to load device'); if (!resp.ok) throw new Error('Failed to load device');
const device = await resp.json(); const device = await resp.json();
showAddDevice(device.device_type || 'wled', device); showAddDevice(device.device_type || 'wled', device);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone device:', error); console.error('Failed to clone device:', error);
showToast(t('device.error.clone_failed'), 'error'); showToast(t('device.error.clone_failed'), 'error');

View File

@@ -5,33 +5,34 @@
import { import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.js'; } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.ts';
import { devicesCache } from '../core/state.js'; import { devicesCache } from '../core/state.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.js'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.ts';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.js'; import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts';
let _deviceTagsInput = null; let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect = null; let _settingsCsptEntitySelect: any = null;
function _ensureSettingsCsptSelect() { function _ensureSettingsCsptSelect() {
const sel = document.getElementById('settings-css-processing-template'); const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
const templates = csptCache.data || []; const templates = csptCache.data || [];
sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` + sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join(''); templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy(); if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy();
if (templates.length > 0) { if (templates.length > 0) {
_settingsCsptEntitySelect = new EntitySelect({ _settingsCsptEntitySelect = new EntitySelect({
target: sel, target: sel,
getItems: () => (csptCache.data || []).map(tp => ({ getItems: () => (csptCache.data || []).map((tp: any) => ({
value: tp.id, value: tp.id,
label: tp.name, label: tp.name,
icon: ICON_TEMPLATE, icon: ICON_TEMPLATE,
@@ -40,7 +41,7 @@ function _ensureSettingsCsptSelect() {
placeholder: window.t ? t('palette.search') : 'Search...', placeholder: window.t ? t('palette.search') : 'Search...',
allowNone: true, allowNone: true,
noneLabel: t('common.none_no_cspt'), noneLabel: t('common.none_no_cspt'),
}); } as any);
} }
} }
@@ -48,40 +49,40 @@ class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); } constructor() { super('device-settings-modal'); }
deviceType = ''; deviceType = '';
capabilities = []; capabilities: string[] = [];
snapshotValues() { snapshotValues() {
return { return {
name: this.$('settings-device-name').value, name: (this.$('settings-device-name') as HTMLInputElement).value,
url: this._getUrl(), url: this._getUrl(),
state_check_interval: this.$('settings-health-interval').value, state_check_interval: (this.$('settings-health-interval') as HTMLInputElement).value,
auto_shutdown: this.$('settings-auto-shutdown').checked, auto_shutdown: (this.$('settings-auto-shutdown') as HTMLInputElement).checked,
led_count: this.$('settings-led-count').value, led_count: (this.$('settings-led-count') as HTMLInputElement).value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb', led_type: (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0', send_latency: (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value || '0',
zones: JSON.stringify(_getCheckedZones('settings-zone-list')), zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
zoneMode: _getZoneMode('settings-zone-mode'), zoneMode: _getZoneMode('settings-zone-mode'),
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []), tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet', dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet',
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0', dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0',
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1', dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
csptId: document.getElementById('settings-css-processing-template')?.value || '', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
}; };
} }
_getUrl() { _getUrl() {
if (isMockDevice(this.deviceType)) { if (isMockDevice(this.deviceType)) {
const deviceId = this.$('settings-device-id')?.value || ''; const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || '';
return `mock://${deviceId}`; return `mock://${deviceId}`;
} }
if (isWsDevice(this.deviceType)) { if (isWsDevice(this.deviceType)) {
const deviceId = this.$('settings-device-id')?.value || ''; const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || '';
return `ws://${deviceId}`; return `ws://${deviceId}`;
} }
if (isSerialDevice(this.deviceType)) { if (isSerialDevice(this.deviceType)) {
return this.$('settings-serial-port').value; return (this.$('settings-serial-port') as HTMLSelectElement).value;
} }
let url = this.$('settings-device-url').value.trim(); let url = (this.$('settings-device-url') as HTMLInputElement).value.trim();
// Append selected zones for OpenRGB // Append selected zones for OpenRGB
if (isOpenrgbDevice(this.deviceType)) { if (isOpenrgbDevice(this.deviceType)) {
const zones = _getCheckedZones('settings-zone-list'); const zones = _getCheckedZones('settings-zone-list');
@@ -96,7 +97,7 @@ class DeviceSettingsModal extends Modal {
const settingsModal = new DeviceSettingsModal(); const settingsModal = new DeviceSettingsModal();
export function formatRelativeTime(isoString) { export function formatRelativeTime(isoString: any) {
if (!isoString) return null; if (!isoString) return null;
const then = new Date(isoString); const then = new Date(isoString);
const diffMs = Date.now() - then.getTime(); const diffMs = Date.now() - then.getTime();
@@ -112,7 +113,7 @@ export function formatRelativeTime(isoString) {
return t('device.last_seen.days').replace('%d', diffDay); return t('device.last_seen.days').replace('%d', diffDay);
} }
export function createDeviceCard(device) { export function createDeviceCard(device: Device & { state?: any }) {
const state = device.state || {}; const state = device.state || {};
const devOnline = state.device_online || false; const devOnline = state.device_online || false;
@@ -155,7 +156,7 @@ export function createDeviceCard(device) {
content: ` content: `
<div class="card-header"> <div class="card-header">
<div class="card-title" title="${escapeHtml(device.name || device.id)}"> <div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span> <span class="card-title-text">${device.name || device.id}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')} ${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel} ${healthLabel}
@@ -164,7 +165,7 @@ export function createDeviceCard(device) {
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span> <span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${openrgbZones.length ${openrgbZones.length
? openrgbZones.map(z => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('') ? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')} : (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''} ${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span> <span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
@@ -193,7 +194,7 @@ export function createDeviceCard(device) {
}); });
} }
export async function turnOffDevice(deviceId) { export async function turnOffDevice(deviceId: any) {
const confirmed = await showConfirm(t('confirm.turn_off_device')); const confirmed = await showConfirm(t('confirm.turn_off_device'));
if (!confirmed) return; if (!confirmed) return;
try { try {
@@ -207,14 +208,14 @@ export async function turnOffDevice(deviceId) {
const error = await setResp.json(); const error = await setResp.json();
showToast(error.detail || 'Failed', 'error'); showToast(error.detail || 'Failed', 'error');
} }
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('device.error.power_off_failed'), 'error'); showToast(t('device.error.power_off_failed'), 'error');
} }
} }
export async function pingDevice(deviceId) { export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`); const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning'); if (btn) btn.classList.add('spinning');
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' }); const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
@@ -231,7 +232,7 @@ export async function pingDevice(deviceId) {
const err = await resp.json(); const err = await resp.json();
showToast(err.detail || 'Ping failed', 'error'); showToast(err.detail || 'Ping failed', 'error');
} }
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('device.ping.error'), 'error'); showToast(t('device.ping.error'), 'error');
} finally { } finally {
@@ -239,11 +240,11 @@ export async function pingDevice(deviceId) {
} }
} }
export function attachDeviceListeners(deviceId) { export function attachDeviceListeners(deviceId: any) {
// Add any specific event listeners here if needed // Add any specific event listeners here if needed
} }
export async function removeDevice(deviceId) { export async function removeDevice(deviceId: any) {
const confirmed = await showConfirm(t('device.remove.confirm')); const confirmed = await showConfirm(t('device.remove.confirm'));
if (!confirmed) return; if (!confirmed) return;
@@ -259,14 +260,14 @@ export async function removeDevice(deviceId) {
const error = await response.json(); const error = await response.json();
showToast(error.detail || t('device.error.remove_failed'), 'error'); showToast(error.detail || t('device.error.remove_failed'), 'error');
} }
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to remove device:', error); console.error('Failed to remove device:', error);
showToast(t('device.error.remove_failed'), 'error'); showToast(t('device.error.remove_failed'), 'error');
} }
} }
export async function showSettings(deviceId) { export async function showSettings(deviceId: any) {
try { try {
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`); const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; } if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
@@ -279,18 +280,18 @@ export async function showSettings(deviceId) {
settingsModal.deviceType = device.device_type; settingsModal.deviceType = device.device_type;
settingsModal.capabilities = caps; settingsModal.capabilities = caps;
document.getElementById('settings-device-id').value = device.id; (document.getElementById('settings-device-id') as HTMLInputElement).value = device.id;
document.getElementById('settings-device-name').value = device.name; (document.getElementById('settings-device-name') as HTMLInputElement).value = device.name;
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30; (document.getElementById('settings-health-interval') as HTMLInputElement).value = device.state_check_interval ?? 30;
const isMock = isMockDevice(device.device_type); const isMock = isMockDevice(device.device_type);
const isWs = isWsDevice(device.device_type); const isWs = isWsDevice(device.device_type);
const isMqtt = isMqttDevice(device.device_type); const isMqtt = isMqttDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-group'); const urlGroup = document.getElementById('settings-url-group') as HTMLElement;
const serialGroup = document.getElementById('settings-serial-port-group'); const serialGroup = document.getElementById('settings-serial-port-group') as HTMLElement;
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]'); const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint = urlGroup.querySelector('.input-hint'); const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
const urlInput = document.getElementById('settings-device-url'); const urlInput = document.getElementById('settings-device-url') as HTMLInputElement;
if (isMock || isWs) { if (isMock || isWs) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
@@ -324,18 +325,18 @@ export async function showSettings(deviceId) {
} }
} }
const ledCountGroup = document.getElementById('settings-led-count-group'); const ledCountGroup = document.getElementById('settings-led-count-group') as HTMLElement;
if (caps.includes('manual_led_count')) { if (caps.includes('manual_led_count')) {
ledCountGroup.style.display = ''; ledCountGroup.style.display = '';
document.getElementById('settings-led-count').value = device.led_count || ''; (document.getElementById('settings-led-count') as HTMLInputElement).value = device.led_count || '';
} else { } else {
ledCountGroup.style.display = 'none'; ledCountGroup.style.display = 'none';
} }
const baudRateGroup = document.getElementById('settings-baud-rate-group'); const baudRateGroup = document.getElementById('settings-baud-rate-group') as HTMLElement;
if (isAdalight) { if (isAdalight) {
baudRateGroup.style.display = ''; baudRateGroup.style.display = '';
const baudSelect = document.getElementById('settings-baud-rate'); const baudSelect = document.getElementById('settings-baud-rate') as HTMLSelectElement;
if (device.baud_rate) { if (device.baud_rate) {
baudSelect.value = String(device.baud_rate); baudSelect.value = String(device.baud_rate);
} else { } else {
@@ -351,16 +352,16 @@ export async function showSettings(deviceId) {
const sendLatencyGroup = document.getElementById('settings-send-latency-group'); const sendLatencyGroup = document.getElementById('settings-send-latency-group');
if (isMock) { if (isMock) {
if (ledTypeGroup) { if (ledTypeGroup) {
ledTypeGroup.style.display = ''; (ledTypeGroup as HTMLElement).style.display = '';
document.getElementById('settings-led-type').value = device.rgbw ? 'rgbw' : 'rgb'; (document.getElementById('settings-led-type') as HTMLSelectElement).value = device.rgbw ? 'rgbw' : 'rgb';
} }
if (sendLatencyGroup) { if (sendLatencyGroup) {
sendLatencyGroup.style.display = ''; (sendLatencyGroup as HTMLElement).style.display = '';
document.getElementById('settings-send-latency').value = device.send_latency_ms || 0; (document.getElementById('settings-send-latency') as HTMLInputElement).value = device.send_latency_ms || 0;
} }
} else { } else {
if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (ledTypeGroup) (ledTypeGroup as HTMLElement).style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (sendLatencyGroup) (sendLatencyGroup as HTMLElement).style.display = 'none';
} }
// WS connection URL // WS connection URL
@@ -372,45 +373,45 @@ export async function showSettings(deviceId) {
const hostPart = origin.replace(/^https?:\/\//, ''); const hostPart = origin.replace(/^https?:\/\//, '');
const apiKey = localStorage.getItem('wled_api_key') || ''; const apiKey = localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`;
document.getElementById('settings-ws-url').value = wsUrl; (document.getElementById('settings-ws-url') as HTMLInputElement).value = wsUrl;
wsUrlGroup.style.display = ''; (wsUrlGroup as HTMLElement).style.display = '';
} else { } else {
wsUrlGroup.style.display = 'none'; (wsUrlGroup as HTMLElement).style.display = 'none';
} }
} }
// Hide health check for devices without health_check capability // Hide health check for devices without health_check capability
const healthIntervalGroup = document.getElementById('settings-health-interval-group'); const healthIntervalGroup = document.getElementById('settings-health-interval-group');
if (healthIntervalGroup) { if (healthIntervalGroup) {
healthIntervalGroup.style.display = caps.includes('health_check') ? '' : 'none'; (healthIntervalGroup as HTMLElement).style.display = caps.includes('health_check') ? '' : 'none';
} }
// Hide auto-restore for devices without auto_restore capability // Hide auto-restore for devices without auto_restore capability
const autoShutdownGroup = document.getElementById('settings-auto-shutdown-group'); const autoShutdownGroup = document.getElementById('settings-auto-shutdown-group');
if (autoShutdownGroup) { if (autoShutdownGroup) {
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none'; (autoShutdownGroup as HTMLElement).style.display = caps.includes('auto_restore') ? '' : 'none';
} }
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; (document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked = !!device.auto_shutdown;
// OpenRGB zone picker + mode toggle // OpenRGB zone picker + mode toggle
const settingsZoneGroup = document.getElementById('settings-zone-group'); const settingsZoneGroup = document.getElementById('settings-zone-group');
const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group'); const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group');
if (settingsZoneModeGroup) settingsZoneModeGroup.style.display = 'none'; if (settingsZoneModeGroup) (settingsZoneModeGroup as HTMLElement).style.display = 'none';
if (settingsZoneGroup) { if (settingsZoneGroup) {
if (isOpenrgbDevice(device.device_type)) { if (isOpenrgbDevice(device.device_type)) {
settingsZoneGroup.style.display = ''; (settingsZoneGroup as HTMLElement).style.display = '';
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url); const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
// Set zone mode radio from device // Set zone mode radio from device
const savedMode = device.zone_mode || 'combined'; const savedMode = device.zone_mode || 'combined';
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`); const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`) as HTMLInputElement | null;
if (modeRadio) modeRadio.checked = true; if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => { _fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them // Re-snapshot after zones are loaded so dirty-check baseline includes them
settingsModal.snapshot(); settingsModal.snapshot();
}); });
} else { } else {
settingsZoneGroup.style.display = 'none'; (settingsZoneGroup as HTMLElement).style.display = 'none';
document.getElementById('settings-zone-list').innerHTML = ''; (document.getElementById('settings-zone-list') as HTMLElement).innerHTML = '';
} }
} }
@@ -419,24 +420,24 @@ export async function showSettings(deviceId) {
const dmxStartUniverseGroup = document.getElementById('settings-dmx-start-universe-group'); const dmxStartUniverseGroup = document.getElementById('settings-dmx-start-universe-group');
const dmxStartChannelGroup = document.getElementById('settings-dmx-start-channel-group'); const dmxStartChannelGroup = document.getElementById('settings-dmx-start-channel-group');
if (isDmxDevice(device.device_type)) { if (isDmxDevice(device.device_type)) {
if (dmxProtocolGroup) dmxProtocolGroup.style.display = ''; if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = '';
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = ''; if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = '';
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = ''; if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = '';
document.getElementById('settings-dmx-protocol').value = device.dmx_protocol || 'artnet'; (document.getElementById('settings-dmx-protocol') as HTMLSelectElement).value = device.dmx_protocol || 'artnet';
ensureDmxProtocolIconSelect('settings-dmx-protocol'); ensureDmxProtocolIconSelect('settings-dmx-protocol');
document.getElementById('settings-dmx-start-universe').value = device.dmx_start_universe ?? 0; (document.getElementById('settings-dmx-start-universe') as HTMLInputElement).value = device.dmx_start_universe ?? 0;
document.getElementById('settings-dmx-start-channel').value = device.dmx_start_channel ?? 1; (document.getElementById('settings-dmx-start-channel') as HTMLInputElement).value = device.dmx_start_channel ?? 1;
// Relabel URL field as IP Address // Relabel URL field as IP Address
const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]'); const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint2 = urlGroup.querySelector('.input-hint'); const urlHint2 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
if (urlLabel2) urlLabel2.textContent = t('device.dmx.url'); if (urlLabel2) urlLabel2.textContent = t('device.dmx.url');
if (urlHint2) urlHint2.textContent = t('device.dmx.url.hint'); if (urlHint2) urlHint2.textContent = t('device.dmx.url.hint');
urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50'; urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50';
} else { } else {
destroyDmxProtocolIconSelect('settings-dmx-protocol'); destroyDmxProtocolIconSelect('settings-dmx-protocol');
if (dmxProtocolGroup) dmxProtocolGroup.style.display = 'none'; if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = 'none';
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none'; if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = 'none';
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none'; if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none';
} }
// Tags // Tags
@@ -449,7 +450,7 @@ export async function showSettings(deviceId) {
// CSPT template selector // CSPT template selector
await csptCache.fetch(); await csptCache.fetch();
_ensureSettingsCsptSelect(); _ensureSettingsCsptSelect();
const csptSel = document.getElementById('settings-css-processing-template'); const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (csptSel) csptSel.value = device.default_css_processing_template_id || ''; if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
settingsModal.snapshot(); settingsModal.snapshot();
@@ -457,7 +458,7 @@ export async function showSettings(deviceId) {
setTimeout(() => desktopFocus(document.getElementById('settings-device-name')), 100); setTimeout(() => desktopFocus(document.getElementById('settings-device-name')), 100);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load device settings:', error); console.error('Failed to load device settings:', error);
showToast(t('device.error.settings_load_failed'), 'error'); showToast(t('device.error.settings_load_failed'), 'error');
@@ -469,8 +470,8 @@ export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _devic
export function closeDeviceSettingsModal() { settingsModal.close(); } export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() { export async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value; const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value;
const name = document.getElementById('settings-device-name').value.trim(); const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
const url = settingsModal._getUrl(); const url = settingsModal._getUrl();
if (!name || !url) { if (!name || !url) {
@@ -479,35 +480,35 @@ export async function saveDeviceSettings() {
} }
try { try {
const body = { const body: any = {
name, url, name, url,
auto_shutdown: document.getElementById('settings-auto-shutdown').checked, auto_shutdown: (document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked,
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30, state_check_interval: parseInt((document.getElementById('settings-health-interval') as HTMLInputElement).value, 10) || 30,
tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [], tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [],
}; };
const ledCountInput = document.getElementById('settings-led-count'); const ledCountInput = document.getElementById('settings-led-count') as HTMLInputElement;
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) { if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10); body.led_count = parseInt(ledCountInput.value, 10);
} }
if (isSerialDevice(settingsModal.deviceType)) { if (isSerialDevice(settingsModal.deviceType)) {
const baudVal = document.getElementById('settings-baud-rate').value; const baudVal = (document.getElementById('settings-baud-rate') as HTMLSelectElement).value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10); if (baudVal) body.baud_rate = parseInt(baudVal, 10);
} }
if (isMockDevice(settingsModal.deviceType)) { if (isMockDevice(settingsModal.deviceType)) {
const sendLatency = document.getElementById('settings-send-latency')?.value; const sendLatency = (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value;
if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10); if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10);
const ledType = document.getElementById('settings-led-type')?.value; const ledType = (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value;
body.rgbw = ledType === 'rgbw'; body.rgbw = ledType === 'rgbw';
} }
if (isOpenrgbDevice(settingsModal.deviceType)) { if (isOpenrgbDevice(settingsModal.deviceType)) {
body.zone_mode = _getZoneMode('settings-zone-mode'); body.zone_mode = _getZoneMode('settings-zone-mode');
} }
if (isDmxDevice(settingsModal.deviceType)) { if (isDmxDevice(settingsModal.deviceType)) {
body.dmx_protocol = document.getElementById('settings-dmx-protocol')?.value || 'artnet'; body.dmx_protocol = (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet';
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10); body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10);
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10); body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10);
} }
const csptId = document.getElementById('settings-css-processing-template')?.value || ''; const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
body.default_css_processing_template_id = csptId; body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT', method: 'PUT',
@@ -516,7 +517,9 @@ export async function saveDeviceSettings() {
if (!deviceResponse.ok) { if (!deviceResponse.ok) {
const errorData = await deviceResponse.json(); const errorData = await deviceResponse.json();
settingsModal.showError(t('device.error.update')); const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
settingsModal.showError(detailStr || t('device.error.update'));
return; return;
} }
@@ -524,20 +527,20 @@ export async function saveDeviceSettings() {
devicesCache.invalidate(); devicesCache.invalidate();
settingsModal.forceClose(); settingsModal.forceClose();
window.loadDevices(); window.loadDevices();
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to save device settings:', err); console.error('Failed to save device settings:', err);
settingsModal.showError(t('device.error.save')); settingsModal.showError(err.message || t('device.error.save'));
} }
} }
// Brightness // Brightness
export function updateBrightnessLabel(deviceId, value) { export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
} }
export async function saveCardBrightness(deviceId, value) { export async function saveCardBrightness(deviceId: any, value: any) {
const bri = parseInt(value); const bri = parseInt(value);
updateDeviceBrightness(deviceId, bri); updateDeviceBrightness(deviceId, bri);
try { try {
@@ -546,16 +549,19 @@ export async function saveCardBrightness(deviceId, value) {
body: JSON.stringify({ brightness: bri }) body: JSON.stringify({ brightness: bri })
}); });
if (!resp.ok) { if (!resp.ok) {
showToast(t('device.error.brightness'), 'error'); const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('device.error.brightness'), 'error');
} }
} catch (err) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
showToast(t('device.error.brightness'), 'error'); showToast(err.message || t('device.error.brightness'), 'error');
} }
} }
const _brightnessFetchInFlight = new Set(); const _brightnessFetchInFlight = new Set();
export async function fetchDeviceBrightness(deviceId) { export async function fetchDeviceBrightness(deviceId: any) {
if (_brightnessFetchInFlight.has(deviceId)) return; if (_brightnessFetchInFlight.has(deviceId)) return;
_brightnessFetchInFlight.add(deviceId); _brightnessFetchInFlight.add(deviceId);
try { try {
@@ -563,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId) {
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness); updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLInputElement | null;
if (slider) { if (slider) {
slider.value = data.brightness; slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%'; slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false; slider.disabled = false;
} }
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`); const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading'); if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) { } catch (err) {
// Silently fail — device may be offline // Silently fail — device may be offline
@@ -592,7 +598,7 @@ const ADALIGHT_HEADER_BYTES = 6; // 'Ada' + count_hi + count_lo + check
const AMBILED_HEADER_BYTES = 1; const AMBILED_HEADER_BYTES = 1;
// FPS hint helpers (shared with device-discovery, targets) // FPS hint helpers (shared with device-discovery, targets)
export function _computeMaxFps(baudRate, ledCount, deviceType) { export function _computeMaxFps(baudRate: any, ledCount: any, deviceType: any) {
if (!ledCount || ledCount < 1) return null; if (!ledCount || ledCount < 1) return null;
if (deviceType === 'wled') { if (deviceType === 'wled') {
const frameUs = ledCount * LED_US_PER_PIXEL + LED_RESET_US; const frameUs = ledCount * LED_US_PER_PIXEL + LED_RESET_US;
@@ -604,7 +610,7 @@ export function _computeMaxFps(baudRate, ledCount, deviceType) {
return Math.floor(baudRate / bitsPerFrame); return Math.floor(baudRate / bitsPerFrame);
} }
export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) { export function _renderFpsHint(hintEl: any, baudRate: any, ledCount: any, deviceType: any) {
const fps = _computeMaxFps(baudRate, ledCount, deviceType); const fps = _computeMaxFps(baudRate, ledCount, deviceType);
if (fps !== null) { if (fps !== null) {
hintEl.textContent = `Max FPS ≈ ${fps}`; hintEl.textContent = `Max FPS ≈ ${fps}`;
@@ -616,14 +622,14 @@ export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
export function updateSettingsBaudFpsHint() { export function updateSettingsBaudFpsHint() {
const hintEl = document.getElementById('settings-baud-fps-hint'); const hintEl = document.getElementById('settings-baud-fps-hint');
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); const baudRate = parseInt((document.getElementById('settings-baud-rate') as HTMLSelectElement).value, 10);
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); const ledCount = parseInt((document.getElementById('settings-led-count') as HTMLInputElement).value, 10);
_renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType); _renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType);
} }
// Settings serial port population (used from showSettings) // Settings serial port population (used from showSettings)
async function _populateSettingsSerialPorts(currentUrl) { async function _populateSettingsSerialPorts(currentUrl: any) {
const select = document.getElementById('settings-serial-port'); const select = document.getElementById('settings-serial-port') as HTMLSelectElement;
select.innerHTML = ''; select.innerHTML = '';
const loadingOpt = document.createElement('option'); const loadingOpt = document.createElement('option');
loadingOpt.value = currentUrl; loadingOpt.value = currentUrl;
@@ -639,7 +645,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
select.innerHTML = ''; select.innerHTML = '';
let currentFound = false; let currentFound = false;
devices.forEach(device => { devices.forEach((device: any) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = device.url; opt.value = device.url;
opt.textContent = device.name; opt.textContent = device.name;
@@ -659,7 +665,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
} }
export function copyWsUrl() { export function copyWsUrl() {
const input = document.getElementById('settings-ws-url'); const input = document.getElementById('settings-ws-url') as HTMLInputElement | null;
if (!input || !input.value) return; if (!input || !input.value) return;
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(input.value).then(() => { navigator.clipboard.writeText(input.value).then(() => {
@@ -681,10 +687,10 @@ document.addEventListener('auth:keyChanged', () => loadDevices());
// ===== OpenRGB zone count enrichment ===== // ===== OpenRGB zone count enrichment =====
// Cache: baseUrl → { zoneName: ledCount, ... } // Cache: baseUrl → { zoneName: ledCount, ... }
const _zoneCountCache = {}; const _zoneCountCache: any = {};
/** Return cached zone LED counts for a base URL, or null if not cached. */ /** Return cached zone LED counts for a base URL, or null if not cached. */
export function getZoneCountCache(baseUrl) { export function getZoneCountCache(baseUrl: any) {
return _zoneCountCache[baseUrl] || null; return _zoneCountCache[baseUrl] || null;
} }
const _zoneCountInFlight = new Set(); const _zoneCountInFlight = new Set();
@@ -693,7 +699,7 @@ const _zoneCountInFlight = new Set();
* Fetch zone LED counts for an OpenRGB device and update zone badges on the card. * Fetch zone LED counts for an OpenRGB device and update zone badges on the card.
* Called after cards are rendered (same pattern as fetchDeviceBrightness). * Called after cards are rendered (same pattern as fetchDeviceBrightness).
*/ */
export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) { export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl); const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl);
if (!zones.length) return; if (!zones.length) return;
@@ -711,7 +717,7 @@ export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
const counts = {}; const counts: any = {};
for (const z of data.zones) { for (const z of data.zones) {
counts[z.name.toLowerCase()] = z.led_count; counts[z.name.toLowerCase()] = z.led_count;
} }
@@ -724,7 +730,7 @@ export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
} }
} }
function _applyZoneCounts(deviceId, zones, counts) { function _applyZoneCounts(deviceId: any, zones: any, counts: any) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`); const card = document.querySelector(`[data-device-id="${deviceId}"]`);
if (!card) return; if (!card) return;
for (const zoneName of zones) { for (const zoneName of zones) {

Some files were not shown because too many files have changed in this diff Show More