Compare commits

...

16 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
05152a0f51 Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes
- Settings modal split into 3 tabs: General, Backup, MQTT
- Log viewer moved to full-screen overlay with compact toolbar
- External URL setting: API endpoints + UI for configuring server domain
  used in webhook/WS URLs instead of auto-detected local IP
- Sources tab tree restructured: Picture Source (Screen Capture/Static/
  Processed sub-groups), Color Strip, Audio, Utility
- TreeNav extended to support nested groups (3-level tree)
- Audio tab split into Sources and Templates sub-tabs
- Fix audio template test: device picker now filters by engine type
  (was showing WASAPI indices for sounddevice templates)
- Audio template test device picker disabled during active test
- Rename "Input Source" to "Source" in CSS test preview (en/ru/zh)
- Fix i18n: log filter/level items deferred to avoid stale t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:16:57 +03:00
191c988cf9 Graph node FPS hover tooltip, full names, no native SVG tooltips
Graph editor:
- Floating FPS tooltip on hover over running output_target nodes (300ms delay)
- Shows errors, uptime, and FPS sparkline seeded from server metrics history
- Tooltip positioned below node with fade-in/out animation
- Uses pointerover/pointerout with relatedTarget check to prevent flicker
- Fixed-width tooltip (200px) with monospace values to prevent layout shift
- Node titles show full names (removed truncate), no native SVG <title> tooltips

Documentation:
- Added duration/numeric formatting conventions to contexts/frontend.md
- Added node hover tooltip docs to contexts/graph-editor.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:45:59 +03:00
afd4a3bc05 Override blend mode, FPS sparkline, fix api_input persistence
New features:
- Override composite blend mode: per-pixel alpha from brightness
  (black=transparent, bright=opaque). Ideal for API input over effects.
- API input test preview FPS chart uses shared createFpsSparkline
  (same look as target card charts)

Fixes:
- Fix api_input source not surviving server restart: from_dict was
  still passing removed led_count field to constructor
- Fix composite layer brightness/processing selectors not aligned:
  labels get fixed width, selects fill remaining space
- Fix CSPT input selector showing in non-CSPT CSS test mode
- Fix test modal LED/FPS controls showing for api_input sources
- Server only sends test WS frames when api_input push_generation changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:12:57 +03:00
be356f30eb Fix HAOS light color reverting after timeout
When HA sets a color via turn_on, also update the source's fallback_color
to match. This way when the api_input timeout fires (default 5s), the
stream reverts to the same color instead of black. turn_off resets
fallback to [0,0,0].

Added coordinator.update_source() for PUT /color-strip-sources/{id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:52:50 +03:00
8a6ffca446 Rework API input CSS: segments, remove led_count, HAOS light, test preview
API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements

Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)

HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:47:42 +03:00
133 changed files with 9491 additions and 5031 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`).
@@ -219,6 +237,31 @@ When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import`
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow. See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
## Duration & Numeric Formatting
### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
- `font-family: var(--font-mono, monospace)` — equal-width characters
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
- Fixed `width` or `min-width` on the value container
- `text-align: right` to anchor the growing edge
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
### FPS sparkline charts
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.js`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
## Visual Graph Editor ## Visual Graph Editor
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions. See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.

View File

@@ -88,6 +88,14 @@ The filter bar (toggled with F or toolbar button) filters nodes by name/kind/sub
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage. Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
## Node hover FPS tooltip
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.js`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
## New entity focus ## New entity focus
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation. When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BUTTON, Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH, Platform.SWITCH,
Platform.SENSOR, Platform.SENSOR,
Platform.NUMBER, Platform.NUMBER,
@@ -111,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
@@ -131,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
@@ -140,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)
@@ -148,6 +157,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_on_coordinator_update) coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
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)
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@@ -163,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()
@@ -336,12 +341,44 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch scene presets: %s", err) _LOGGER.warning("Failed to fetch scene presets: %s", err)
return [] return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None: async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset.""" """Activate a scene preset."""
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()
@@ -352,13 +389,29 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status() resp.raise_for_status()
await self.async_request_refresh() await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
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()
@@ -374,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)
@@ -392,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

@@ -0,0 +1,151 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Restore state from fallback_color
fallback = self._get_fallback_color()
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
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
# Update fallback_color so the color persists beyond the timeout
await self.coordinator.update_source(
self._source_id, fallback_color=scaled,
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing black and setting fallback to black."""
off_color = [0, 0, 0]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
)
await self.coordinator.update_source(
self._source_id, fallback_color=off_color,
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]

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

@@ -0,0 +1,19 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:

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"
@@ -66,5 +71,21 @@
"name": "Brightness Source" "name": "Brightness Source"
} }
} }
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
} }
} }

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()
@@ -89,7 +90,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
webhook_url = None webhook_url = None
for c in automation.conditions: for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token: if isinstance(c, WebhookCondition) and c.token:
if request: # Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}" webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
else: else:
webhook_url = f"/api/v1/webhooks/{c.token}" webhook_url = f"/api/v1/webhooks/{c.token}"
@@ -108,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,
) )
@@ -158,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))
@@ -244,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:
@@ -410,7 +415,8 @@ async def push_colors(
): ):
"""Push raw LED colors to an api_input color strip source. """Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source. Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
The payload is forwarded to all running stream instances for this source.
""" """
try: try:
source = store.get_source(source_id) source = store.get_source(source_id)
@@ -420,15 +426,27 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource): if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type") raise HTTPException(status_code=400, detail="Source is not an api_input type")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
if body.segments is not None:
# Segment-based path
seg_dicts = [s.model_dump() for s in body.segments]
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
return {
"status": "ok",
"streams_updated": len(streams),
"segments_applied": len(body.segments),
}
else:
# Legacy flat colors path
colors_array = np.array(body.colors, dtype=np.uint8) colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets") raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
stream.push_colors(colors_array) stream.push_colors(colors_array)
return { return {
"status": "ok", "status": "ok",
"streams_updated": len(streams), "streams_updated": len(streams),
@@ -708,18 +726,41 @@ async def css_api_input_ws(
break break
if "text" in message: if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]} # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json import json
try: try:
data = json.loads(message["text"]) data = json.loads(message["text"])
raw_colors = data.get("colors", []) except (json.JSONDecodeError, ValueError) as e:
await websocket.send_json({"error": str(e)})
continue
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
await websocket.send_json({"error": f"Invalid segment: {e}"})
continue
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
continue
elif "colors" in data:
try:
raw_colors = data["colors"]
colors_array = np.array(raw_colors, dtype=np.uint8) colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"}) await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue continue
except (json.JSONDecodeError, ValueError, TypeError) as e: except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)}) await websocket.send_json({"error": str(e)})
continue continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message: elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED) # Binary frame: raw RGBRGB... bytes (3 bytes per LED)
@@ -732,7 +773,7 @@ async def css_api_input_ws(
else: else:
continue continue
# Push to all running streams # Push to all running streams (colors_array path only reaches here)
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
@@ -799,6 +840,10 @@ async def test_color_strip_ws(
try: try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
# Send metadata as first message # Send metadata as first message
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource) is_composite = isinstance(source, CompositeColorStripSource)
@@ -874,6 +919,15 @@ async def test_color_strip_ws(
await websocket.send_bytes(b''.join(parts)) await websocket.send_bytes(b''.join(parts))
elif composite_colors is not None: elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes()) await websocket.send_bytes(composite_colors.tobytes())
else:
# For api_input: only send when new data was pushed
if is_api_input:
gen = stream.push_generation
if gen != _last_push_gen:
_last_push_gen = gen
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else: else:
colors = stream.get_latest_colors() colors = stream.get_latest_colors()
if colors is not None: if colors is not None:

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

@@ -43,6 +43,8 @@ from wled_controller.api.schemas.system import (
BackupListResponse, BackupListResponse,
DisplayInfo, DisplayInfo,
DisplayListResponse, DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo, GpuInfo,
HealthResponse, HealthResponse,
LogLevelRequest, LogLevelRequest,
@@ -66,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:
@@ -94,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
@@ -155,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)}
@@ -203,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:
@@ -263,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,
@@ -763,6 +770,63 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
) )
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Live log viewer WebSocket # Live log viewer WebSocket
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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

@@ -3,7 +3,7 @@
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration from wled_controller.api.schemas.devices import Calibration
@@ -31,7 +31,7 @@ class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source.""" """A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source") source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen") blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active") enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources") count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel): class SegmentPayload(BaseModel):
"""Request to push raw LED colors to an api_input source.""" """A single segment for segment-based LED color updates."""
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)") start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
if self.mode == "solid":
if self.color is None or len(self.color) != 3:
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
if not all(0 <= c <= 255 for c in self.color):
raise ValueError("solid color values must be 0-255")
elif self.mode == "per_pixel":
if not self.colors:
raise ValueError("per_pixel mode requires non-empty 'colors' list")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color in per_pixel must be [R,G,B]")
elif self.mode == "gradient":
if not self.colors or len(self.colors) < 2:
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color stop in gradient must be [R,G,B]")
return self
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source.
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
if self.colors is None and self.segments is None:
raise ValueError("Either 'colors' or 'segments' must be provided")
return self
class NotifyRequest(BaseModel): class NotifyRequest(BaseModel):

View File

@@ -143,6 +143,20 @@ class MQTTSettingsRequest(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix") base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ───────────────────────────────────────
class ExternalUrlResponse(BaseModel):
"""External URL setting response."""
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
class ExternalUrlRequest(BaseModel):
"""External URL setting update request."""
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Log level schemas ───────────────────────────────────────── # ─── Log level schemas ─────────────────────────────────────────
class LogLevelResponse(BaseModel): class LogLevelResponse(BaseModel):

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

@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`. received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler, Thread-safe: push_colors() / push_segments() can be called from any thread
WebSocket handler) while get_latest_colors() is called from the target (REST handler, WebSocket handler) while get_latest_colors() is called from
processor thread. the target processor thread.
""" """
import threading import threading
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_DEFAULT_LED_COUNT = 150
class ApiInputColorStripStream(ColorStripStream): class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data. """Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to push_colors() or push_segments(). A background thread checks for timeout
fallback_color when no data arrives within the configured timeout window. and reverts to fallback_color when no data arrives within the configured
timeout window.
""" """
def __init__(self, source): def __init__(self, source):
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._auto_size = not source.led_count self._led_count = _DEFAULT_LED_COUNT
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
# Build initial fallback buffer # Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0 self._last_push_time: float = 0.0
self._timed_out = True # Start in timed-out state self._timed_out = True # Start in timed-out state
self._push_generation: int = 0 # Incremented on each push; used by test WS
def _build_fallback(self, led_count: int) -> np.ndarray: def _build_fallback(self, led_count: int) -> np.ndarray:
"""Build a (led_count, 3) uint8 array filled with fallback_color.""" """Build a (led_count, 3) uint8 array filled with fallback_color."""
@@ -59,40 +62,128 @@ class ApiInputColorStripStream(ColorStripStream):
(led_count, 1), (led_count, 1),
) )
def _ensure_capacity(self, required: int) -> None:
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
if required > self._led_count:
self._led_count = required
self._fallback_array = self._build_fallback(self._led_count)
# Preserve existing data if not timed out
if not self._timed_out:
new_buf = self._fallback_array.copy()
old_len = min(len(self._colors), required)
new_buf[:old_len] = self._colors[:old_len]
self._colors = new_buf
else:
self._colors = self._fallback_array.copy()
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
def push_colors(self, colors: np.ndarray) -> None: def push_colors(self, colors: np.ndarray) -> None:
"""Push a new frame of LED colors. """Push a new frame of LED colors.
Thread-safe. The array is truncated or zero-padded to match led_count. Thread-safe. Auto-grows the buffer if the incoming array is larger
than the current buffer; otherwise truncates or zero-pads.
Args: Args:
colors: np.ndarray shape (N, 3) uint8 colors: np.ndarray shape (N, 3) uint8
""" """
with self._lock: with self._lock:
n = len(colors) n = len(colors)
# Auto-grow if incoming data is larger
if n > self._led_count:
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:
elif n > self._led_count: np.copyto(self._colors, colors, casting='unsafe')
self._colors = colors[:self._led_count].astype(np.uint8)
else: else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
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)
padded[:n] = colors[:n] padded[:n] = colors[:n]
self._colors = padded self._colors = padded
self._last_push_time = time.monotonic() self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False
def push_segments(self, segments: list) -> None:
"""Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed.
Args:
segments: list of dicts with keys:
start (int) starting LED index
length (int) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
"""
# Compute required buffer size from all segments
max_index = max(seg["start"] + seg["length"] for seg in segments)
with self._lock:
# Auto-grow buffer if needed
if max_index > self._led_count:
self._ensure_capacity(max_index)
# Start from current buffer (or fallback if timed out)
if self._timed_out:
buf = self._fallback_array.copy()
else:
buf = self._colors.copy()
for seg in segments:
start = seg["start"]
length = seg["length"]
mode = seg["mode"]
end = start + length
if mode == "solid":
color = np.array(seg["color"], dtype=np.uint8)
buf[start:end] = color
elif mode == "per_pixel":
colors = np.array(seg["colors"], dtype=np.uint8)
available = len(colors)
if available >= length:
buf[start:end] = colors[:length]
else:
# Pad with zeros if fewer colors than length
buf[start:start + available] = colors
buf[start + available:end] = 0
elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32)
num_stops = len(stops)
# Positions of stops evenly spaced 0..length-1
stop_positions = np.linspace(0, length - 1, num_stops)
pixel_positions = np.arange(length, dtype=np.float32)
for ch in range(3):
buf[start:end, ch] = np.interp(
pixel_positions,
stop_positions,
stops[:, ch],
).astype(np.uint8)
self._colors = buf
self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False self._timed_out = False
def configure(self, device_led_count: int) -> None: def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start). """Set LED count from the target device (called on target start).
Only takes effect when led_count was 0 (auto-size). Always resizes the buffer to the device LED count.
""" """
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: if device_led_count > 0 and device_led_count != self._led_count:
with self._lock: with self._lock:
self._led_count = device_led_count self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count) self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._timed_out = True self._timed_out = True
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs") logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
@@ -131,6 +222,11 @@ class ApiInputColorStripStream(ColorStripStream):
with self._lock: with self._lock:
return self._colors return self._colors
@property
def push_generation(self) -> int:
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
return self._push_generation
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update fallback_color and timeout from updated source config.""" """Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource from wled_controller.storage.color_strip_source import ApiInputColorStripSource
@@ -138,15 +234,6 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
prev_led_count = self._led_count if self._auto_size else None
self._auto_size = not source.led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
# Preserve runtime LED count across updates if auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._lock: with self._lock:
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out: if self._timed_out:

View File

@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
_BLEND_ADD = "add" _BLEND_ADD = "add"
_BLEND_MULTIPLY = "multiply" _BLEND_MULTIPLY = "multiply"
_BLEND_SCREEN = "screen" _BLEND_SCREEN = "screen"
_BLEND_OVERRIDE = "override"
class CompositeColorStripStream(ColorStripStream): class CompositeColorStripStream(ColorStripStream):
@@ -47,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
@@ -110,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
@@ -164,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
@@ -192,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)
@@ -300,11 +314,34 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Override blend: per-pixel alpha derived from top brightness.
Black pixels are fully transparent (bottom shows through),
bright pixels fully opaque (top replaces bottom). Layer opacity
scales the per-pixel alpha.
"""
u16a, u16b = self._u16_a, self._u16_b
# Per-pixel brightness = max(R, G, B) for each LED
per_px_alpha = np.max(top, axis=1, keepdims=True).astype(np.uint16)
# Scale by layer opacity
per_px_alpha = (per_px_alpha * alpha) >> 8
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - per_px_alpha)
u16b *= per_px_alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
_BLEND_DISPATCH = { _BLEND_DISPATCH = {
_BLEND_NORMAL: "_blend_normal", _BLEND_NORMAL: "_blend_normal",
_BLEND_ADD: "_blend_add", _BLEND_ADD: "_blend_add",
_BLEND_MULTIPLY: "_blend_multiply", _BLEND_MULTIPLY: "_blend_multiply",
_BLEND_SCREEN: "_blend_screen", _BLEND_SCREEN: "_blend_screen",
_BLEND_OVERRIDE: "_blend_override",
} }
# ── Processing loop ───────────────────────────────────────── # ── Processing loop ─────────────────────────────────────────
@@ -332,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):
@@ -388,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)
@@ -401,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 */
@@ -1172,3 +1174,62 @@ html:has(#tab-graph.active) {
background: var(--border-color); background: var(--border-color);
margin: 4px 0; margin: 4px 0;
} }
/* ── Node hover FPS tooltip ── */
.graph-node-tooltip {
position: absolute;
z-index: 50;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 14px var(--shadow-color, rgba(0,0,0,0.25));
padding: 8px 12px;
pointer-events: none;
font-size: 0.8rem;
width: 200px;
color: var(--text-color);
}
.graph-node-tooltip .gnt-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
line-height: 1.6;
}
.graph-node-tooltip .gnt-label {
color: var(--text-muted);
white-space: nowrap;
}
.graph-node-tooltip .gnt-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
text-align: right;
min-width: 72px;
display: inline-block;
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
}
.graph-node-tooltip .gnt-fps-row {
margin-top: 4px;
padding: 2px 0;
background: transparent;
}
.graph-node-tooltip.gnt-fade-in {
animation: gntFadeIn 0.15s ease-out forwards;
}
.graph-node-tooltip.gnt-fade-out {
animation: gntFadeOut 0.12s ease-in forwards;
}
@keyframes gntFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gntFadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}

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 */
@@ -269,6 +269,8 @@
font-size: 0.9em; font-size: 0.9em;
} }
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
/* Composite layers preview */ /* Composite layers preview */
.css-test-layers { .css-test-layers {
display: flex; display: flex;
@@ -346,7 +348,107 @@
opacity: 1; opacity: 1;
} }
/* ── Log viewer ─────────────────────────────────────────────── */ /* ── Settings modal tabs ───────────────────────────────────── */
.settings-tab-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color);
padding: 0 1.25rem;
}
.settings-tab-btn {
background: none;
border: none;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s ease, border-color 0.25s ease;
}
.settings-tab-btn:hover {
color: var(--text-color);
}
.settings-tab-btn.active {
color: var(--primary-text-color);
border-bottom-color: var(--primary-color);
}
.settings-panel {
display: none;
}
.settings-panel.active {
display: block;
animation: tabFadeIn 0.25s ease-out;
}
/* ── Log viewer overlay (full-screen) ──────────────────────── */
.log-overlay {
position: fixed;
inset: 0;
z-index: var(--z-log-overlay);
display: flex;
flex-direction: column;
background: var(--bg-color, #111);
padding: 12px 16px;
animation: fadeIn 0.2s ease-out;
}
.log-overlay-close {
position: absolute;
top: 8px;
right: 12px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.3rem;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
z-index: 1;
transition: color 0.15s, background 0.15s;
}
.log-overlay-close:hover {
color: var(--text-color);
background: var(--border-color);
}
.log-overlay-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 10px;
padding-right: 36px; /* space for corner close btn */
flex-shrink: 0;
}
.log-overlay-toolbar h3 {
margin: 0;
font-size: 1rem;
white-space: nowrap;
margin-right: 4px;
}
.log-overlay .log-viewer-output {
flex: 1;
max-height: none;
border-radius: 8px;
min-height: 0;
}
/* ── Log viewer base ───────────────────────────────────────── */
.log-viewer-output { .log-viewer-output {
background: #0d0d0d; background: #0d0d0d;
@@ -905,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;
@@ -1414,6 +1516,19 @@
min-width: 0; min-width: 0;
} }
.composite-layer-brightness-label {
flex-shrink: 0;
width: 90px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.composite-layer-brightness,
.composite-layer-cspt {
flex: 1;
min-width: 0;
}
.composite-layer-blend { .composite-layer-blend {
width: 100px; width: 100px;
flex-shrink: 0; flex-shrink: 0;

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,97 +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;
} }
/* ── Children (leaves) ── */ /* ── Leaf (clickable item) ── */
.tree-children { .tree-dd-leaf {
display: flex;
align-items: center;
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;
}
.tree-dd-leaf.active .tree-count {
background: var(--primary-color);
color: var(--primary-contrast);
}
.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);
@@ -191,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,21 +173,24 @@ 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, downloadBackup, handleRestoreFileSelected, openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected,
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
restartServer, saveMqttSettings, restartServer, saveMqttSettings,
loadApiKeysList, loadApiKeysList,
downloadPartialExport, handlePartialImportFileSelected, downloadPartialExport, handlePartialImportFileSelected,
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
} from './features/settings.js'; saveExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts';
// ─── Register all HTML onclick / onchange / onfocus globals ─── // ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -208,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,
@@ -272,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,
@@ -366,6 +370,7 @@ Object.assign(window, {
// automations // automations
loadAutomations, loadAutomations,
switchAutomationTab,
openAutomationEditor, openAutomationEditor,
closeAutomationEditorModal, closeAutomationEditorModal,
saveAutomationEditor, saveAutomationEditor,
@@ -374,8 +379,6 @@ Object.assign(window, {
cloneAutomation, cloneAutomation,
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
expandAllAutomationSections,
collapseAllAutomationSections,
// scene presets // scene presets
openScenePresetCapture, openScenePresetCapture,
@@ -401,7 +404,6 @@ Object.assign(window, {
// targets // targets
loadTargetsTab, loadTargetsTab,
switchTargetSubTab, switchTargetSubTab,
expandAllTargetSections, collapseAllTargetSections,
showTargetEditor, showTargetEditor,
closeTargetEditorModal, closeTargetEditorModal,
forceCloseTargetEditorModal, forceCloseTargetEditorModal,
@@ -522,9 +524,10 @@ Object.assign(window, {
openCommandPalette, openCommandPalette,
closeCommandPalette, closeCommandPalette,
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level) // settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
openSettingsModal, openSettingsModal,
closeSettingsModal, closeSettingsModal,
switchSettingsTab,
downloadBackup, downloadBackup,
handleRestoreFileSelected, handleRestoreFileSelected,
saveAutoBackupSettings, saveAutoBackupSettings,
@@ -540,8 +543,12 @@ Object.assign(window, {
disconnectLogViewer, disconnectLogViewer,
clearLogViewer, clearLogViewer,
applyLogFilter, applyLogFilter,
openLogOverlay,
closeLogOverlay,
loadLogLevel, loadLogLevel,
setLogLevel, setLogLevel,
saveExternalUrl,
getBaseOrigin,
}); });
// ─── Global keyboard shortcuts ─── // ─── Global keyboard shortcuts ───
@@ -569,11 +576,14 @@ document.addEventListener('keydown', (e) => {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals via stack // Close in order: log overlay > overlay lightboxes > modals via stack
if (document.getElementById('display-picker-lightbox').classList.contains('active')) { const logOverlay = document.getElementById('log-overlay');
closeDisplayPicker(); if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
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();
} }
@@ -605,6 +615,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale (dispatches languageChanged which may trigger API calls) // Initialize locale (dispatches languageChanged which may trigger API calls)
await initLocale(); await initLocale();
// Load external URL setting early so getBaseOrigin() is available for card rendering
loadExternalUrl();
// Restore active tab before showing content to avoid visible jump // Restore active tab before showing content to avoid visible jump
initTabs(); initTabs();

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 = {
@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
pattern_template: patternTemplatesCache, pattern_template: patternTemplatesCache,
}; };
/** Maps entity_type to the window load function that refreshes its UI. */
const ENTITY_LOADER_MAP = {
device: 'loadTargetsTab',
output_target: 'loadTargetsTab',
color_strip_source: 'loadTargetsTab',
pattern_template: 'loadTargetsTab',
picture_source: 'loadPictureSources',
audio_source: 'loadPictureSources',
value_source: 'loadPictureSources',
sync_clock: 'loadPictureSources',
capture_template: 'loadPictureSources',
audio_template: 'loadPictureSources',
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
};
/** Debounce timers per loader function name coalesces rapid WS events and
* avoids a redundant re-render when the local save handler already triggered one. */
const _loaderTimers = {};
const _LOADER_DEBOUNCE_MS = 600;
function _invalidateAndReload(entityType) { function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType]; const cache = ENTITY_CACHE_MAP[entityType];
if (cache) { if (!cache) return;
cache.fetch({ force: true });
const oldData = cache.data;
cache.fetch({ force: true }).then((newData) => {
// Skip UI refresh if the data didn't actually change —
// the local save handler already refreshed the UI.
if (oldData === newData) return;
if (Array.isArray(oldData) && Array.isArray(newData) &&
oldData.length === newData.length &&
JSON.stringify(oldData) === JSON.stringify(newData)) return;
const loader = ENTITY_LOADER_MAP[entityType];
if (loader) {
clearTimeout(_loaderTimers[loader]);
_loaderTimers[loader] = setTimeout(() => {
delete _loaderTimers[loader];
if (typeof (window as any)[loader] === 'function') (window as any)[loader]();
}, _LOADER_DEBOUNCE_MS);
} }
});
document.dispatchEvent(new CustomEvent('entity:reload', { document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType }, detail: { entity_type: entityType },
})); }));

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);
@@ -292,7 +341,7 @@ function renderNode(node, callbacks) {
class: 'graph-node-title', class: 'graph-node-title',
x: 16, y: 24, x: 16, y: 24,
}); });
title.textContent = truncate(name, 18); title.textContent = name;
g.appendChild(title); g.appendChild(title);
// Subtitle (type) // Subtitle (type)
@@ -305,11 +354,6 @@ function renderNode(node, callbacks) {
g.appendChild(sub); g.appendChild(sub);
} }
// Tooltip
const tip = svgEl('title');
tip.textContent = `${name} (${kind.replace(/_/g, ' ')})`;
g.appendChild(tip);
// Hover overlay (action buttons) // Hover overlay (action buttons)
const overlay = _createOverlay(node, width, callbacks); const overlay = _createOverlay(node, width, callbacks);
g.appendChild(overlay); g.appendChild(overlay);
@@ -338,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)) {
@@ -458,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;
@@ -499,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}"]`);
@@ -510,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);
@@ -526,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,275 +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:
* [
* { key, titleKey, icon?, children: [{ key, titleKey, icon?, count, subTab?, sectionKey? }] },
* { 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));
}
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
container.querySelectorAll('[data-tree-group]').forEach(groupEl => {
let total = 0;
groupEl.querySelectorAll('.tree-leaf .tree-count').forEach(cnt => {
total += parseInt(cnt.textContent, 10) || 0;
});
const groupCount = groupEl.querySelector('.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();
for (const item of this._items) {
if (item.children) {
for (const child of item.children) {
this._leafMap.set(child.key, child);
}
} 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);
}
return this._renderStandalone(item);
}).join('');
container.innerHTML = html +
(this._extraHtml ? `<div class="tree-extra">${this._extraHtml}</div>` : '');
this._bindEvents(container);
}
_renderGroup(group, collapsed) {
const isCollapsed = !!collapsed[group.key];
const groupCount = group.children.reduce((sum, c) => sum + (c.count || 0), 0);
return `
<div class="tree-group" 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' : ''}">
${group.children.map(leaf => `
<div class="tree-leaf${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>
`).join('')}
</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 } 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 {
@@ -155,8 +155,9 @@ export async function saveAudioSource() {
} }
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose(); audioSourceModal.forceClose();
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 = '';
} }
@@ -164,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');
} }
@@ -178,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'));
@@ -186,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');
} }
@@ -194,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;
@@ -205,8 +206,9 @@ export async function deleteAudioSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('audio_source.deleted'), 'success'); showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e: any) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
@@ -214,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();
@@ -240,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;
@@ -260,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('');
@@ -270,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;
} }
@@ -278,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 =>
@@ -309,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 =>
@@ -331,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);
} }
} }
@@ -348,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');
@@ -375,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
@@ -431,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;
@@ -448,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,23 +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 { IconSelect } from '../core/icon-select.js'; import { getBaseOrigin } from './settings.ts';
import { EntitySelect } from '../core/entity-palette.js'; import { IconSelect } from '../core/icon-select.ts';
import { attachProcessPicker } from '../core/process-picker.js'; import { EntitySelect } from '../core/entity-palette.ts';
import { csScenes, createSceneCard } from './scene-presets.js'; import { attachProcessPicker } from '../core/process-picker.ts';
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'); }
@@ -29,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');
@@ -58,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)
@@ -90,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>`;
@@ -100,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) })));
@@ -118,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();
@@ -130,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');
@@ -220,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 = '';
@@ -242,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')}`;
@@ -270,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;
} }
@@ -298,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);
@@ -315,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
@@ -334,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() {
@@ -344,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 => ({
@@ -355,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>`
@@ -374,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;
} }
@@ -384,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');
@@ -395,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 =====
@@ -434,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';
@@ -450,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;
@@ -546,7 +579,7 @@ function addAutomationConditionRow(condition) {
} }
if (type === 'webhook') { if (type === 'webhook') {
if (data.token) { if (data.token) {
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token; const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small> <small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
@@ -594,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
@@ -604,7 +637,7 @@ function addAutomationConditionRow(condition) {
target: matchSel, target: matchSel,
items: _buildMatchTypeItems(), items: _buildMatchTypeItems(),
columns: 2, columns: 2,
}); } as any);
} }
} }
@@ -620,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' });
@@ -631,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 });
} }
@@ -668,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) {
@@ -684,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() : [],
}; };
@@ -706,29 +739,34 @@ export async function saveAutomationEditor() {
automationModal.forceClose(); automationModal.forceClose();
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success'); showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
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();
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;
@@ -744,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;
@@ -765,10 +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();
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({

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