Compare commits
16 Commits
823cb90d2d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c696bae3 | |||
| 43fbc1eff5 | |||
| 997ff2fd70 | |||
| 55772b58dd | |||
| 968046d96b | |||
| 122e95545c | |||
| f4647027d2 | |||
| cdba98813b | |||
| 1f047d6561 | |||
| 6a31814900 | |||
| ea9b05733b | |||
| 05152a0f51 | |||
| 191c988cf9 | |||
| afd4a3bc05 | |||
| be356f30eb | |||
| 8a6ffca446 |
@@ -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
|
|
||||||
74
.gitea/workflows/release.yml
Normal file
74
.gitea/workflows/release.yml
Normal 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
250
build-dist.ps1
Normal 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 ""
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
151
custom_components/wled_screen_controller/light.py
Normal file
151
custom_components/wled_screen_controller/light.py
Normal 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]
|
||||||
@@ -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
|
|
||||||
|
|||||||
19
custom_components/wled_screen_controller/services.yaml
Normal file
19
custom_components/wled_screen_controller/services.yaml
Normal 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:
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Источник яркости"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
22
server/package-lock.json
generated
22
server/package-lock.json
generated
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) =====
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 0–250: values >250 are command bytes in AmbiLED protocol
|
# Clamp to 0–250: 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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.0–1.0)."""
|
"""Return current scalar value (0.0–1.0)."""
|
||||||
return 1.0
|
...
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Acquire resources (if any)."""
|
"""Acquire resources (if any)."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 ── */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
|
||||||
128
server/src/wled_controller/static/js/core/bulk-toolbar.ts
Normal file
128
server/src/wled_controller/static/js/core/bulk-toolbar.ts
Normal 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')}">✕</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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 `
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
}));
|
}));
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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]) {
|
||||||
@@ -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;
|
||||||
@@ -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, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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());
|
||||||
@@ -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);
|
||||||
@@ -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')));
|
||||||
});
|
});
|
||||||
@@ -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 => {
|
||||||
@@ -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"/>';
|
||||||
@@ -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!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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; }
|
||||||
@@ -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');
|
||||||
@@ -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 || [],
|
|
||||||
});
|
|
||||||
320
server/src/wled_controller/static/js/core/state.ts
Normal file
320
server/src/wled_controller/static/js/core/state.ts
Normal 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 || [],
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -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'}">▶</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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
353
server/src/wled_controller/static/js/core/tree-nav.ts
Normal file
353
server/src/wled_controller/static/js/core/tree-nav.ts
Normal 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">▾</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user