Comprehensive WebUI review: 41 UX/feature/CSS improvements

Safety & Correctness:
- Add confirmation dialogs to Stop All, turnOffDevice
- i18n confirm dialog (title, yes, no buttons)
- Fix duplicate tutorial-overlay ID
- Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg)
- Fix toast z-index conflict with confirm dialog (2500 → 3000)

UX Consistency:
- Add backdrop-close to test modals
- Add device clone feature (only entity without it)
- Add sync clocks to command palette
- Replace 20+ hardcoded accent colors with CSS vars/color-mix()
- Remove dead .badge duplicate from components.css
- Make calibration elements keyboard-accessible (div → button)
- Add aria-labels to color picker swatches
- Fix pattern canvas mobile horizontal scroll
- Fix graph editor mobile bottom clipping

Polish:
- Add empty-state messages to all CardSection instances
- Convert 21 px font-sizes to rem
- Add scroll-behavior: smooth with reduced-motion override
- Add @media print styles
- Add :focus-visible to 4 missing interactive elements
- Fix settings modal close label (Cancel → Close)
- Fix api-key submit button i18n

New Features:
- Command palette actions: start/stop targets, activate scenes, enable/disable
- Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop)
- OS notification history viewer modal
- Scene "used by" automation reference count on cards
- Clock elapsed time display on Streams tab cards
- Device "last seen" relative timestamp on cards
- Audio device refresh button in edit modal
- Composite layer drag-to-reorder
- MQTT settings panel (broker config with JSON persistence)
- WebSocket log viewer with level filtering and ring buffer
- Runtime log-level adjustment (GET/PUT endpoints + settings UI)
- Animated value source waveform canvas preview
- Gradient custom preset save/delete (localStorage)
- API key read-only display in settings
- Backup metadata (file size, auto/manual badges)
- Server restart button with confirm + overlay
- Partial config export/import per entity type
- Progressive disclosure in target editor (Advanced section)

CSS Architecture:
- Define radius scale tokens (--radius-sm/md/lg/pill)
- Scope .cs-filter selectors to remove 7 !important overrides
- Consolidate duplicate toggle switch (filter-list → settings-toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 18:46:38 +03:00
parent a4a0e39b9b
commit 304fa24389
47 changed files with 2594 additions and 250 deletions

61
TODO.md
View File

@@ -66,3 +66,64 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
- [x] `P1` **Daylight brightness value source** — New value source type that reports a 0255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic - [x] `P1` **Daylight brightness value source** — New value source type that reports a 0255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
- [x] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout - [x] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
## WebUI Review (2026-03-16)
### Critical (Safety & Correctness)
- [x] `P1` **"Stop All" buttons need confirmation** — dashboard, LED targets, KC targets
- [x] `P1` **`turnOffDevice()` needs confirmation**
- [x] `P1` **Confirm dialog i18n** — added data-i18n to title/buttons
- [x] `P1` **Duplicate `id="tutorial-overlay"`** — renamed to calibration-tutorial-overlay
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
- [x] `P1` **Toast z-index conflict** — toast now 3000
### UX Consistency
- [x] `P1` **Test modals backdrop-close** — added setupBackdropClose
- [x] `P1` **Devices clone** — added cloneDevice with full field prefill
- [x] `P1` **Sync clocks in command palette** — added to _responseKeys + _buildItems
- [x] `P2` **Hardcoded accent colors** — 20+ replacements using color-mix() and CSS vars
- [x] `P2` **Duplicate `.badge` definition** — removed dead code from components.css
- [x] `P2` **Calibration elements keyboard-accessible** — changed div to button
- [x] `P2` **Color-picker swatch aria-labels** — added aria-label with hex value
- [x] `P2` **Pattern canvas mobile scroll** — added min-width: 0 override in mobile.css
- [x] `P2` **Graph editor mobile bottom clipping** — adjusted height in mobile.css
### Low Priority Polish
- [x] `P3` **Empty-state illustrations/onboarding** — CardSection emptyKey with per-entity messages
- [x] `P3` **api-key-modal submit title i18n**
- [x] `P3` **Settings modal close labeled "Cancel" → "Close"**
- [x] `P3` **Inconsistent px vs rem font sizes** — 21 conversions across streams/modal/cards CSS
- [x] `P3` **scroll-behavior: smooth** — added with reduced-motion override
- [x] `P3` **Reduce !important usage** — scoped .cs-filter selectors
- [x] `P3` **@media print styles** — theme reset + hide nav
- [x] `P3` **:focus-visible on interactive elements** — added 4 missing selectors
- [x] `P3` **iOS Safari modal scroll-position jump** — already implemented in ui.js lockBody/unlockBody
### New Features
- [x] `P1` **Command palette actions** — start/stop targets, activate scenes, enable/disable automations
- [x] `P1` **Bulk start/stop API** — POST /output-targets/bulk/start and /bulk/stop
- [x] `P1` **OS notification history viewer** — modal with app name, timestamp, fired/filtered badges
- [x] `P1` **Scene "used by" reference count** — badge on card with automation count
- [x] `P1` **Clock elapsed time on cards** — shows formatted elapsed time
- [x] `P1` **Device "last seen" timestamp** — relative time with full ISO in title
- [x] `P2` **Audio device refresh in modal** — refresh button next to device dropdown
- [x] `P2` **Composite layer reorder** — drag handles with pointer-based reorder
- [x] `P2` **MQTT settings panel** — config form with enabled/host/port/auth/topic, JSON persistence
- [x] `P2` **Log viewer** — WebSocket broadcaster with ring buffer, level-filtered UI in settings
- [x] `P2` **Animated value source waveform preview** — canvas drawing of sine/triangle/sawtooth/square
- [x] `P2` **Gradient custom preset save** — localStorage-backed custom presets with save/delete
- [x] `P2` **API key management UI** — read-only display of key labels with masked values
- [x] `P2` **Backup metadata** — file size, auto/manual badge
- [x] `P2` **Server restart button** — in settings with confirm dialog + restart overlay
- [x] `P2` **Partial config export/import** — per-store export/import with merge option
- [x] `P3` **Audio spectrum visualizer** — already fully implemented
- [ ] `P3` **Hue bridge pairing flow** — requires physical Hue bridge hardware
- [x] `P3` **Runtime log-level adjustment** — GET/PUT endpoints + settings dropdown
- [x] `P3` **Progressive disclosure in target editor** — advanced section collapsed by default
### CSS Architecture
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
- [x] `P2` **Define radius scale** — --radius-sm/md/lg/pill tokens, migrated key selectors
- [x] `P2` **Scope generic input selector** — .cs-filter boosted specificity, 7 !important removed
- [x] `P2` **Consolidate duplicate toggle switch** — filter-list uses settings-toggle
- [x] `P2` **Replace hardcoded accent colors** — 20+ values → CSS vars with color-mix()

View File

@@ -22,6 +22,8 @@ from wled_controller.api.dependencies import (
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.output_targets import ( from wled_controller.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
ExtractedColorResponse, ExtractedColorResponse,
KCTestRectangleResponse, KCTestRectangleResponse,
KCTestResponse, KCTestResponse,
@@ -373,6 +375,64 @@ async def delete_target(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_start_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
started: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
msg = str(e)
for t in target_store.get_all_targets():
if t.id in msg:
msg = msg.replace(t.id, f"'{t.name}'")
errors[target_id] = msg
except Exception as e:
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
stopped: list[str] = []
errors: dict[str, str] = {}
for target_id in body.ids:
try:
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
errors[target_id] = str(e)
return BulkTargetResponse(stopped=stopped, errors=errors)
# ===== PROCESSING CONTROL ENDPOINTS ===== # ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"]) @router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import io import io
import json import json
import logging
import platform import platform
import subprocess import subprocess
import sys import sys
@@ -12,7 +13,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import psutil import psutil
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -44,6 +45,10 @@ from wled_controller.api.schemas.system import (
DisplayListResponse, DisplayListResponse,
GpuInfo, GpuInfo,
HealthResponse, HealthResponse,
LogLevelRequest,
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
PerformanceResponse, PerformanceResponse,
ProcessListResponse, ProcessListResponse,
RestoreResponse, RestoreResponse,
@@ -331,6 +336,125 @@ def _schedule_restart() -> None:
threading.Thread(target=_restart, daemon=True).start() threading.Thread(target=_restart, daemon=True).start()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config()
keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
def export_store(store_key: str, _: AuthRequired):
"""Download a single entity store as a JSON file."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
export = {
"meta": {
"format": "ledgrab-partial-export",
"format_version": 1,
"store_key": store_key,
"app_version": __version__,
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
},
"store": data,
}
content = json.dumps(export, indent=2, ensure_ascii=False)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-{store_key}-{timestamp}.json"
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
async def import_store(
store_key: str,
_: AuthRequired,
file: UploadFile = File(...),
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
):
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
if store_key not in STORE_MAP:
raise HTTPException(
status_code=404,
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
)
try:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Support both full-backup format and partial-export format
if "stores" in payload and isinstance(payload.get("meta"), dict):
# Full backup: extract the specific store
if payload["meta"].get("format") not in ("ledgrab-backup",):
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
stores = payload.get("stores", {})
if store_key not in stores:
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
incoming = stores[store_key]
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
# Partial export format
if payload["meta"].get("store_key") != store_key:
raise HTTPException(
status_code=400,
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
)
incoming = payload.get("store", {})
else:
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
if not isinstance(incoming, dict):
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
config = get_config()
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
def _write():
if merge and file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict):
existing.update(incoming)
atomic_write_json(file_path, existing)
return len(existing)
atomic_write_json(file_path, incoming)
return len(incoming)
count = await asyncio.to_thread(_write)
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
"status": "imported",
"store_key": store_key,
"entries": count,
"merge": merge,
"restart_scheduled": True,
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
}
@router.get("/api/v1/system/backup", tags=["System"]) @router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired): def backup_config(_: AuthRequired):
"""Download all configuration as a single JSON backup file.""" """Download all configuration as a single JSON backup file."""
@@ -366,6 +490,13 @@ def backup_config(_: AuthRequired):
) )
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
_schedule_restart()
return {"status": "restarting"}
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"]) @router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
async def restore_config( async def restore_config(
_: AuthRequired, _: AuthRequired,
@@ -532,6 +663,160 @@ async def delete_saved_backup(
return {"status": "deleted", "filename": filename} return {"status": "deleted", "filename": filename}
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
_MQTT_SETTINGS_FILE: Path | None = None
def _get_mqtt_settings_path() -> Path:
global _MQTT_SETTINGS_FILE
if _MQTT_SETTINGS_FILE is None:
cfg = get_config()
# Derive the data directory from any known storage file path
data_dir = Path(cfg.storage.devices_file).parent
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
return _MQTT_SETTINGS_FILE
def _load_mqtt_settings() -> dict:
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
path = _get_mqtt_settings_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
overrides = json.load(f)
defaults.update(overrides)
except Exception as e:
logger.warning(f"Failed to load MQTT settings override file: {e}")
return defaults
def _save_mqtt_settings(settings: dict) -> None:
"""Persist MQTT settings to the JSON override file."""
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_mqtt_settings_path(), settings)
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings()
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings()
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
_save_mqtt_settings(new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
@router.websocket("/api/v1/system/logs/ws")
async def logs_ws(
websocket: WebSocket,
token: str = Query(""),
):
"""WebSocket that streams server log lines in real time.
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
# Ensure the broadcaster knows the event loop (may be first connection)
log_broadcaster.ensure_loop()
# Subscribe *before* reading the backlog so no lines slip through
queue = log_broadcaster.subscribe()
try:
# Send backlog first
for line in log_broadcaster.get_backlog():
await websocket.send_text(line)
# Stream new lines
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
await websocket.send_text(line)
except asyncio.TimeoutError:
# Send a keepalive ping so the connection stays alive
try:
await websocket.send_text("")
except Exception:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
log_broadcaster.unsubscribe(queue)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine) # ADB helpers (for Android / scrcpy engine)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -601,3 +886,34 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
raise HTTPException(status_code=500, detail="adb not found on PATH") raise HTTPException(status_code=500, detail="adb not found on PATH")
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ADB disconnect timed out") raise HTTPException(status_code=504, detail="ADB disconnect timed out")
# ─── Log level ─────────────────────────────────────────────────
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def get_log_level(_: AuthRequired):
"""Get the current root logger log level."""
level_int = logging.getLogger().getEffectiveLevel()
return LogLevelResponse(level=logging.getLevelName(level_int))
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
"""Change the root logger log level at runtime (no server restart required)."""
level_name = body.level.upper()
if level_name not in _VALID_LOG_LEVELS:
raise HTTPException(
status_code=400,
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
)
level_int = getattr(logging, level_name)
root = logging.getLogger()
root.setLevel(level_int)
# Also update all handlers so they actually emit at the new level
for handler in root.handlers:
handler.setLevel(level_int)
logger.info("Log level changed to %s", level_name)
return LogLevelResponse(level=level_name)

View File

@@ -177,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
last_update: Optional[datetime] = Field(None, description="Last update timestamp") last_update: Optional[datetime] = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
"""Request body for bulk start/stop operations."""
ids: List[str] = Field(description="List of target IDs to operate on")
class BulkTargetResponse(BaseModel):
"""Response for bulk start/stop operations."""
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
class KCTestRectangleResponse(BaseModel): class KCTestRectangleResponse(BaseModel):
"""A rectangle with its extracted color from a KC test.""" """A rectangle with its extracted color from a KC test."""

View File

@@ -115,3 +115,43 @@ class BackupListResponse(BaseModel):
backups: List[BackupFileInfo] backups: List[BackupFileInfo]
count: int count: int
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(description="MQTT username (empty = anonymous)")
password_set: bool = Field(description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
class MQTTSettingsRequest(BaseModel):
"""MQTT broker settings update request."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(default="", description="MQTT username (empty = anonymous)")
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── Log level schemas ─────────────────────────────────────────
class LogLevelResponse(BaseModel):
"""Current log level response."""
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
class LogLevelRequest(BaseModel):
"""Request to change the log level."""
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")

View File

@@ -39,10 +39,11 @@ from wled_controller.core.devices.mqtt_client import set_mqtt_service
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.os_notification_listener import OsNotificationListener from wled_controller.core.processing.os_notification_listener import OsNotificationListener
from wled_controller.api.routes.system import STORE_MAP from wled_controller.api.routes.system import STORE_MAP
from wled_controller.utils import setup_logging, get_logger from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
# Initialize logging # Initialize logging
setup_logging() setup_logging()
install_broadcast_handler()
logger = get_logger(__name__) logger = get_logger(__name__)
# Get configuration # Get configuration

View File

@@ -12,6 +12,11 @@
--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;
--radius: 8px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 100px;
} }
/* ── SVG icon base ── */ /* ── SVG icon base ── */
@@ -34,6 +39,7 @@
--bg-secondary: #242424; --bg-secondary: #242424;
--card-bg: #2d2d2d; --card-bg: #2d2d2d;
--text-color: #e0e0e0; --text-color: #e0e0e0;
--text-primary: #e0e0e0;
--text-secondary: #999; --text-secondary: #999;
--text-muted: #777; --text-muted: #777;
--border-color: #404040; --border-color: #404040;
@@ -41,6 +47,8 @@
--primary-text-color: #66bb6a; --primary-text-color: #66bb6a;
--success-color: #28a745; --success-color: #28a745;
--shadow-color: rgba(0, 0, 0, 0.3); --shadow-color: rgba(0, 0, 0, 0.3);
--hover-bg: rgba(255, 255, 255, 0.05);
--input-bg: #1a1a2e;
color-scheme: dark; color-scheme: dark;
} }
@@ -50,6 +58,7 @@
--bg-secondary: #eee; --bg-secondary: #eee;
--card-bg: #ffffff; --card-bg: #ffffff;
--text-color: #333333; --text-color: #333333;
--text-primary: #333333;
--text-secondary: #666; --text-secondary: #666;
--text-muted: #999; --text-muted: #999;
--border-color: #e0e0e0; --border-color: #e0e0e0;
@@ -57,6 +66,8 @@
--primary-text-color: #3d8b40; --primary-text-color: #3d8b40;
--success-color: #2e7d32; --success-color: #2e7d32;
--shadow-color: rgba(0, 0, 0, 0.12); --shadow-color: rgba(0, 0, 0, 0.12);
--hover-bg: rgba(0, 0, 0, 0.05);
--input-bg: #f0f0f0;
color-scheme: light; color-scheme: light;
} }
@@ -69,6 +80,7 @@ body {
html { html {
background: var(--bg-color); background: var(--bg-color);
overflow-y: scroll; overflow-y: scroll;
scroll-behavior: smooth;
} }
body { body {
@@ -184,4 +196,16 @@ header {
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
html { scroll-behavior: auto; }
}
@media print {
[data-theme] {
--bg-color: #fff;
--card-bg: #fff;
--text-color: #000;
--text-secondary: #333;
--border-color: #ccc;
}
.tab-bar, .app-header, .scroll-to-top, .toast-container { display: none; }
} }

View File

@@ -28,7 +28,7 @@ section {
.add-device-card:hover { .add-device-card:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
background: rgba(33, 150, 243, 0.05); background: color-mix(in srgb, var(--primary-color) 5%, transparent); /* --primary-color tint */
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -53,7 +53,7 @@ section {
.card { .card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--radius-md);
padding: 12px 20px 20px; padding: 12px 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;
@@ -243,7 +243,7 @@ section {
.card-drag-placeholder { .card-drag-placeholder {
border: 2px dashed var(--primary-color); border: 2px dashed var(--primary-color);
border-radius: 8px; border-radius: 8px;
background: rgba(33, 150, 243, 0.04); background: color-mix(in srgb, var(--primary-color) 4%, transparent); /* --primary-color tint */
min-height: 80px; min-height: 80px;
transition: none; transition: none;
} }
@@ -378,7 +378,7 @@ body.cs-drag-active .card-drag-handle {
.card-power-btn:hover { .card-power-btn:hover {
color: var(--primary-text-color); color: var(--primary-text-color);
background: rgba(76, 175, 80, 0.1); background: color-mix(in srgb, var(--primary-color) 10%, transparent); /* --primary-color tint */
} }
.card-remove-btn { .card-remove-btn {
@@ -401,7 +401,7 @@ body.cs-drag-active .card-drag-handle {
.card-remove-btn:hover { .card-remove-btn:hover {
color: var(--danger-color); color: var(--danger-color);
background: rgba(244, 67, 54, 0.1); background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
} }
.card-header { .card-header {
@@ -569,7 +569,7 @@ body.cs-drag-active .card-drag-handle {
} }
.zone-checkbox-list .zone-loading, .zone-checkbox-list .zone-loading,
.zone-checkbox-list .zone-error { .zone-checkbox-list .zone-error {
font-size: 12px; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
padding: 4px 0; padding: 4px 0;
} }
@@ -581,7 +581,7 @@ body.cs-drag-active .card-drag-handle {
padding: 4px 6px; padding: 4px 6px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 0.8125rem;
transition: background 0.15s; transition: background 0.15s;
} }
.zone-checkbox-item:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); } .zone-checkbox-item:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
@@ -602,7 +602,7 @@ body.cs-drag-active .card-drag-handle {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 0.8125rem;
} }
.zone-mode-option input[type="radio"] { margin: 0; } .zone-mode-option input[type="radio"] { margin: 0; }
@@ -629,7 +629,7 @@ body.cs-drag-active .card-drag-handle {
.display-card { .display-card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--radius-md);
padding: 15px; padding: 15px;
} }
@@ -676,7 +676,10 @@ body.cs-drag-active .card-drag-handle {
.layout-display.primary { .layout-display.primary {
border-color: var(--primary-color); border-color: var(--primary-color);
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15), rgba(76, 175, 80, 0.05)); background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color) 15%, transparent), /* --primary-color tint */
color-mix(in srgb, var(--primary-color) 5%, transparent) /* --primary-color tint */
);
} }
.layout-display.secondary { .layout-display.secondary {
@@ -768,7 +771,7 @@ body.cs-drag-active .card-drag-handle {
margin: 0 0 15px 0; margin: 0 0 15px 0;
line-height: 1.5; line-height: 1.5;
padding: 8px 12px; padding: 8px 12px;
background: rgba(33, 150, 243, 0.08); background: color-mix(in srgb, var(--info-color, #2196F3) 8%, transparent); /* --info-color tint */
border-left: 3px solid var(--info-color, #2196F3); border-left: 3px solid var(--info-color, #2196F3);
border-radius: 0 6px 6px 0; border-radius: 0 6px 6px 0;
} }
@@ -939,13 +942,13 @@ ul.section-tip li {
transition: flex 0.3s ease; transition: flex 0.3s ease;
} }
.timing-extract { background: #4CAF50; } .timing-extract { background: var(--primary-color); }
.timing-map { background: #FF9800; } .timing-map { background: #FF9800; }
.timing-smooth { background: #2196F3; } .timing-smooth { background: var(--info-color, #2196F3); }
.timing-send { background: #E91E63; } .timing-send { background: #E91E63; }
.timing-audio-read { background: #4CAF50; } .timing-audio-read { background: var(--primary-color); }
.timing-audio-fft { background: #FF9800; } .timing-audio-fft { background: #FF9800; }
.timing-audio-render { background: #2196F3; } .timing-audio-render { background: var(--info-color, #2196F3); }
.timing-legend { .timing-legend {
display: flex; display: flex;
@@ -969,13 +972,13 @@ ul.section-tip li {
border-radius: 2px; border-radius: 2px;
} }
.timing-dot.timing-extract { background: #4CAF50; } .timing-dot.timing-extract { background: var(--primary-color); }
.timing-dot.timing-map { background: #FF9800; } .timing-dot.timing-map { background: #FF9800; }
.timing-dot.timing-smooth { background: #2196F3; } .timing-dot.timing-smooth { background: var(--info-color, #2196F3); }
.timing-dot.timing-send { background: #E91E63; } .timing-dot.timing-send { background: #E91E63; }
.timing-dot.timing-audio-read { background: #4CAF50; } .timing-dot.timing-audio-read { background: var(--primary-color); }
.timing-dot.timing-audio-fft { background: #FF9800; } .timing-dot.timing-audio-fft { background: #FF9800; }
.timing-dot.timing-audio-render { background: #2196F3; } .timing-dot.timing-audio-render { background: var(--info-color, #2196F3); }
@media (max-width: 768px) { @media (max-width: 768px) {
.displays-grid, .displays-grid,

View File

@@ -1,25 +1,3 @@
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.badge.processing {
background: var(--primary-color);
color: var(--primary-contrast);
}
.badge.idle {
background: var(--warning-color);
color: white;
}
.badge.error {
background: var(--danger-color);
color: white;
}
.card-content { .card-content {
margin-bottom: 15px; margin-bottom: 15px;
} }
@@ -60,7 +38,7 @@
.btn { .btn {
padding: 8px 16px; padding: 8px 16px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
@@ -182,7 +160,7 @@ select {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--radius-sm);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 1rem; font-size: 1rem;
@@ -351,7 +329,7 @@ input:-webkit-autofill:focus {
.overlay-preview-img { .overlay-preview-img {
max-width: 80vw; max-width: 80vw;
max-height: 50vh; max-height: 50vh;
border-radius: 8px; border-radius: var(--radius-md);
margin-top: 16px; margin-top: 16px;
object-fit: contain; object-fit: contain;
} }
@@ -369,13 +347,13 @@ input:-webkit-autofill:focus {
left: 50%; left: 50%;
transform: translateX(-50%) translateY(100px); transform: translateX(-50%) translateY(100px);
padding: 16px 24px; padding: 16px 24px;
border-radius: 8px; border-radius: var(--radius-md);
color: white; color: white;
font-weight: 600; font-weight: 600;
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: 2500; z-index: 3000;
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;
@@ -424,7 +402,7 @@ input:-webkit-autofill:focus {
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary)); background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent); border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
padding: 1px 7px; padding: 1px 7px;
border-radius: 8px; border-radius: var(--radius-md);
white-space: nowrap; white-space: nowrap;
line-height: 1.4; line-height: 1.4;
} }
@@ -439,7 +417,7 @@ input:-webkit-autofill:focus {
gap: 4px; gap: 4px;
padding: 6px 8px; padding: 6px 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--radius-sm);
background: var(--bg-color); background: var(--bg-color);
cursor: text; cursor: text;
min-height: 38px; min-height: 38px;
@@ -501,7 +479,7 @@ input:-webkit-autofill:focus {
z-index: 1000; z-index: 1000;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--shadow-color); box-shadow: 0 4px 12px var(--shadow-color);
margin-top: 4px; margin-top: 4px;
max-height: 200px; max-height: 200px;
@@ -538,7 +516,11 @@ input:-webkit-autofill:focus {
.dashboard-action-btn:focus-visible, .dashboard-action-btn:focus-visible,
.btn-expand-collapse:focus-visible, .btn-expand-collapse:focus-visible,
.btn-filter-action:focus-visible, .btn-filter-action:focus-visible,
.settings-toggle:focus-visible { .settings-toggle:focus-visible,
.discovery-item:focus-visible,
.tag-chip-remove:focus-visible,
.cs-filter-reset:focus-visible,
.graph-filter-clear:focus-visible {
outline: 2px solid var(--primary-color); outline: 2px solid var(--primary-color);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -586,7 +568,7 @@ textarea:focus-visible {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--radius-sm);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 1rem; font-size: 1rem;
@@ -720,7 +702,7 @@ textarea:focus-visible {
overflow-y: auto; overflow-y: auto;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: var(--radius-lg);
box-shadow: 0 16px 48px var(--shadow-color); box-shadow: 0 16px 48px var(--shadow-color);
padding: 16px; padding: 16px;
opacity: 0; opacity: 0;
@@ -790,7 +772,7 @@ textarea:focus-visible {
max-height: 60vh; max-height: 60vh;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -878,7 +860,7 @@ textarea:focus-visible {
background: var(--bg-color); background: var(--bg-color);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--radius-sm);
padding: 10px; padding: 10px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;

View File

@@ -190,12 +190,12 @@ h2 {
.health-dot.health-online { .health-dot.health-online {
background-color: var(--primary-color); background-color: var(--primary-color);
box-shadow: 0 0 6px rgba(76, 175, 80, 0.6); box-shadow: 0 0 6px color-mix(in srgb, var(--primary-color) 60%, transparent); /* --primary-color glow */
} }
.health-dot.health-offline { .health-dot.health-offline {
background-color: var(--danger-color); background-color: var(--danger-color);
box-shadow: 0 0 6px rgba(244, 67, 54, 0.6); box-shadow: 0 0 6px color-mix(in srgb, var(--danger-color) 60%, transparent); /* --danger-color glow */
} }
.health-dot.health-unknown { .health-dot.health-unknown {
@@ -565,9 +565,23 @@ h2 {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #4caf50; background: var(--primary-color);
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 0 4px #4caf50; box-shadow: 0 0 4px var(--primary-color);
}
.cp-action-item .cp-icon {
opacity: 0.85;
}
.cp-action-item .cp-detail {
font-weight: 600;
color: var(--primary-color);
}
.cp-action-item.cp-active .cp-detail {
color: var(--primary-contrast);
opacity: 0.9;
} }
.cp-loading, .cp-loading,

View File

@@ -219,6 +219,11 @@
padding-bottom: 64px; padding-bottom: 64px;
} }
/* Graph editor: account for fixed bottom tab bar (~64px) */
.graph-container {
height: calc(100vh - var(--header-height, 60px) - 74px);
}
/* ── Container ── */ /* ── Container ── */
.container { .container {
padding: 8px; padding: 8px;
@@ -441,6 +446,9 @@
gap: 10px; gap: 10px;
padding: 6px 10px; padding: 6px 10px;
} }
/* Pattern canvas — prevent horizontal overflow */
.pattern-canvas-container { min-width: 0; width: 100%; }
} }

View File

@@ -108,7 +108,7 @@
.vs-test-value-large { .vs-test-value-large {
font-size: 1.3em; font-size: 1.3em;
color: #4caf50; color: var(--primary-text-color);
} }
.vs-test-status { .vs-test-status {
@@ -149,7 +149,7 @@
border-radius: 6px; border-radius: 6px;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
color: #fff; color: #fff;
font-size: 16px; font-size: 1rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -333,6 +333,38 @@
opacity: 1; opacity: 1;
} }
/* ── Log viewer ─────────────────────────────────────────────── */
.log-viewer-output {
background: #0d0d0d;
color: #d4d4d4;
font-family: var(--font-mono, 'Consolas', 'Courier New', monospace);
font-size: 0.75rem;
line-height: 1.45;
padding: 0.6rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color);
max-height: 400px;
overflow-y: auto;
overflow-x: auto;
white-space: pre;
margin: 0;
/* Scroll performance */
contain: strict;
}
.log-viewer-output .log-line-error {
color: #f48771;
}
.log-viewer-output .log-line-warning {
color: #ce9178;
}
.log-viewer-output .log-line-debug {
color: #6a9955;
}
/* LED count control */ /* LED count control */
.css-test-led-control { .css-test-led-control {
display: flex; display: flex;
@@ -391,7 +423,7 @@
.modal-content { .modal-content {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: var(--radius-lg);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
max-height: calc(100vh - 40px); max-height: calc(100vh - 40px);
@@ -562,8 +594,8 @@
.hint-toggle.active { .hint-toggle.active {
opacity: 1; opacity: 1;
color: var(--primary-text-color, #4CAF50); color: var(--primary-text-color);
border-color: var(--primary-color, #4CAF50); border-color: var(--primary-color);
} }
.input-hint { .input-hint {
@@ -695,6 +727,17 @@
min-width: auto; min-width: auto;
} }
.select-with-action {
display: flex;
gap: 6px;
align-items: center;
}
.select-with-action select {
flex: 1;
min-width: 0;
}
.fps-hint { .fps-hint {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
@@ -767,7 +810,7 @@
} }
.error-message { .error-message {
background: rgba(244, 67, 54, 0.1); background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
color: var(--danger-color); color: var(--danger-color);
padding: 12px; padding: 12px;
@@ -805,7 +848,7 @@
.btn-display-picker:hover { .btn-display-picker:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 15%, transparent); /* --primary-color tint */
} }
.modal-content-wide { .modal-content-wide {
@@ -983,14 +1026,14 @@
} }
.layout-display-pickable:hover { .layout-display-pickable:hover {
box-shadow: 0 0 20px rgba(76, 175, 80, 0.4); box-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent); /* --primary-color glow */
border-color: var(--primary-color) !important; border-color: var(--primary-color) !important;
} }
.layout-display-pickable.selected { .layout-display-pickable.selected {
border-color: var(--primary-color) !important; border-color: var(--primary-color) !important;
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5); box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 50%, transparent); /* --primary-color glow */
background: rgba(76, 175, 80, 0.12) !important; background: color-mix(in srgb, var(--primary-color) 12%, transparent) !important; /* --primary-color tint */
} }
/* ── Device picker list (cameras, scrcpy) ─────────────────────── */ /* ── Device picker list (cameras, scrcpy) ─────────────────────── */
@@ -1133,6 +1176,33 @@
flex: 1; flex: 1;
} }
/* ── Custom gradient presets list ───────────────────────────── */
.custom-presets-list {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.custom-preset-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 6px;
background: var(--surface-2, rgba(255,255,255,0.04));
border: 1px solid var(--border-color, rgba(255,255,255,0.1));
}
.custom-preset-name {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Color Cycle editor ──────────────────────────────────────── */ /* ── Color Cycle editor ──────────────────────────────────────── */
#color-cycle-colors-list { #color-cycle-colors-list {
@@ -1211,6 +1281,71 @@
line-height: 1; line-height: 1;
} }
/* ── Notification history list ─────────────────────────────────── */
.notif-history-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.82rem;
}
.notif-history-row:last-child {
border-bottom: none;
}
.notif-history-app {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.notif-history-time {
color: var(--text-muted);
white-space: nowrap;
font-size: 0.78rem;
flex-shrink: 0;
}
.notif-history-badges {
display: flex;
gap: 3px;
flex-shrink: 0;
}
.notif-history-badge {
display: inline-block;
min-width: 18px;
padding: 0 4px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 600;
text-align: center;
line-height: 18px;
}
.notif-history-badge--fired {
background: var(--primary-color);
color: #fff;
}
.notif-history-badge--filtered {
background: var(--border-color);
color: var(--text-muted);
}
.notif-history-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
/* ── Composite layer editor ────────────────────────────────────── */ /* ── Composite layer editor ────────────────────────────────────── */
#composite-layers-list { #composite-layers-list {
@@ -1269,3 +1404,58 @@
flex: 0 0 26px; flex: 0 0 26px;
} }
/* ── Composite layer drag-to-reorder ── */
.composite-layer-drag-handle {
cursor: grab;
opacity: 0;
color: var(--text-secondary);
font-size: 0.75rem;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
transition: opacity 0.2s ease;
user-select: none;
touch-action: none;
flex-shrink: 0;
}
.composite-layer-item:hover .composite-layer-drag-handle {
opacity: 0.5;
}
.composite-layer-drag-handle:hover {
opacity: 1 !important;
background: var(--border-color);
}
.composite-layer-drag-handle:active {
cursor: grabbing;
}
.composite-layer-drag-clone {
position: fixed;
z-index: 9999;
pointer-events: none;
opacity: 0.92;
transform: scale(1.02);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
will-change: top;
}
.composite-layer-drag-placeholder {
border: 2px dashed var(--primary-color);
border-radius: 4px;
background: rgba(33, 150, 243, 0.04);
min-height: 42px;
transition: height 0.15s ease;
}
body.composite-layer-dragging .composite-layer-item {
transition: none !important;
}
body.composite-layer-dragging .composite-layer-drag-handle {
opacity: 0 !important;
}

View File

@@ -11,7 +11,7 @@
.template-card { .template-card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--radius-md);
padding: 16px; padding: 16px;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition: box-shadow 0.2s ease, transform 0.2s ease;
display: flex; display: flex;
@@ -92,20 +92,20 @@
.badge { .badge {
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 0.75rem;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.template-description { .template-description {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 14px; font-size: 0.875rem;
margin-bottom: 12px; margin-bottom: 12px;
line-height: 1.4; line-height: 1.4;
} }
.template-config { .template-config {
font-size: 14px; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -166,7 +166,7 @@
.template-no-config { .template-no-config {
margin: 12px 0; margin: 12px 0;
font-size: 13px; font-size: 0.8125rem;
color: var(--primary-text-color); color: var(--primary-text-color);
font-weight: 500; font-weight: 500;
padding: 4px 0; padding: 4px 0;
@@ -176,7 +176,7 @@
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
border-collapse: collapse; border-collapse: collapse;
font-size: 13px; font-size: 0.8125rem;
} }
.config-table td { .config-table td {
@@ -246,7 +246,7 @@
.text-muted { .text-muted {
color: var(--text-secondary); color: var(--text-secondary);
font-style: italic; font-style: italic;
font-size: 13px; font-size: 0.8125rem;
} }
/* PP Filter List in Template Modal */ /* PP Filter List in Template Modal */
@@ -259,7 +259,7 @@
.pp-filter-empty { .pp-filter-empty {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 13px; font-size: 0.8125rem;
text-align: center; text-align: center;
padding: 16px; padding: 16px;
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
@@ -294,13 +294,13 @@
.pp-filter-card-name { .pp-filter-card-name {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);
} }
.pp-filter-card-summary { .pp-filter-card-summary {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 0.75rem;
margin-right: 8px; margin-right: 8px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -360,7 +360,7 @@
.pp-filter-option label { .pp-filter-option label {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 12px; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -375,55 +375,21 @@
border-radius: 4px; border-radius: 4px;
background: var(--card-bg); background: var(--card-bg);
color: var(--text-primary); color: var(--text-primary);
font-size: 12px; font-size: 0.75rem;
font-family: monospace; font-family: monospace;
} }
.pp-filter-option-bool label { /* Bool option row: label text on left, .settings-toggle on right */
justify-content: space-between; .pp-filter-option-bool {
gap: 8px; flex-direction: row;
align-items: center; align-items: center;
cursor: pointer; justify-content: space-between;
padding: 4px 0; padding: 4px 0;
} }
.pp-filter-option-bool input[type="checkbox"] { .pp-filter-option-label {
appearance: none; font-size: 0.75rem;
-webkit-appearance: none; color: var(--text-secondary);
width: 34px;
min-width: 34px;
height: 18px;
background: var(--border-color);
border-radius: 9px;
position: relative;
cursor: pointer;
transition: background 0.2s;
order: 1;
margin: 0;
}
.pp-filter-option-bool input[type="checkbox"]::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.pp-filter-option-bool input[type="checkbox"]:checked {
background: var(--primary-color);
}
.pp-filter-option-bool input[type="checkbox"]:checked::after {
transform: translateX(16px);
}
.pp-filter-option-bool span {
order: 0;
} }
/* ── PP filter drag-and-drop ── */ /* ── PP filter drag-and-drop ── */
@@ -432,7 +398,7 @@
cursor: grab; cursor: grab;
opacity: 0; opacity: 0;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 0.75rem;
line-height: 1; line-height: 1;
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
@@ -492,7 +458,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
border-radius: 6px; border-radius: 6px;
background: var(--card-bg); background: var(--card-bg);
color: var(--text-primary); color: var(--text-primary);
font-size: 13px; font-size: 0.8125rem;
} }
.pp-add-filter-btn { .pp-add-filter-btn {
@@ -506,7 +472,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
border-radius: 6px; border-radius: 6px;
background: var(--card-bg); background: var(--card-bg);
color: var(--text-primary); color: var(--text-primary);
font-size: 20px; font-size: 1.25rem;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
line-height: 1; line-height: 1;
@@ -526,7 +492,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.template-test-section h3 { .template-test-section h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 12px; margin-bottom: 12px;
font-size: 16px; font-size: 1rem;
} }
.test-results-container { .test-results-container {
@@ -550,7 +516,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.test-performance-section h4 { .test-performance-section h4 {
margin-top: 0; margin-top: 0;
margin-bottom: 12px; margin-bottom: 12px;
font-size: 14px; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -579,7 +545,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
align-items: center; align-items: center;
padding: 10px 0; padding: 10px 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 14px; font-size: 0.875rem;
} }
.stat-item:last-child { .stat-item:last-child {
@@ -602,7 +568,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
text-align: center; text-align: center;
padding: 40px 20px; padding: 40px 20px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 16px; font-size: 1rem;
} }
/* Stream type badges */ /* Stream type badges */
@@ -619,7 +585,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
/* Stream info panel in stream selector modal */ /* Stream info panel in stream selector modal */
.stream-info-panel { .stream-info-panel {
padding: 4px 0 0 0; padding: 4px 0 0 0;
font-size: 14px; font-size: 0.875rem;
line-height: 1.6; line-height: 1.6;
} }
@@ -792,23 +758,23 @@ body.pp-filter-dragging .pp-filter-drag-handle {
flex-shrink: 0; flex-shrink: 0;
} }
.cs-filter { .cs-filter-wrap .cs-filter {
width: 100%; width: 100%;
padding: 4px 26px 4px 10px !important; padding: 4px 26px 4px 10px;
font-size: 0.78rem !important; font-size: 0.78rem;
border: 1px solid var(--border-color) !important; border: 1px solid var(--border-color);
border-radius: 14px !important; border-radius: 14px;
background: var(--bg-secondary) !important; background: var(--bg-secondary);
color: var(--text-color) !important; color: var(--text-color);
outline: none; outline: none;
box-shadow: none !important; box-shadow: none;
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.2s, background 0.2s, width 0.2s; transition: border-color 0.2s, background 0.2s, width 0.2s;
} }
.cs-filter:focus { .cs-filter-wrap .cs-filter:focus {
border-color: var(--primary-color) !important; border-color: var(--primary-color);
background: var(--bg-color) !important; background: var(--bg-color);
} }
.cs-filter::placeholder { .cs-filter::placeholder {
@@ -838,6 +804,22 @@ body.pp-filter-dragging .pp-filter-drag-handle {
background: var(--border-color); background: var(--border-color);
} }
/* Empty state for CardSection */
.cs-empty-state {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
text-align: center;
}
.cs-empty-text {
font-size: 0.9rem;
color: var(--text-muted);
font-style: italic;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.templates-grid { .templates-grid {

View File

@@ -102,6 +102,7 @@ import {
import { import {
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus, onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice, showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
cloneDevice,
} from './features/device-discovery.js'; } from './features/device-discovery.js';
import { import {
loadTargetsTab, switchTargetSubTab, loadTargetsTab, switchTargetSubTab,
@@ -123,6 +124,10 @@ import {
mappedAddZone, mappedRemoveZone, mappedAddZone, mappedRemoveZone,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
onGradientPresetChange,
promptAndSaveGradientPreset,
applyCustomGradientPreset,
deleteAndRefreshGradientPreset,
cloneColorStrip, cloneColorStrip,
toggleCSSOverlay, toggleCSSOverlay,
previewCSSFromEditor, previewCSSFromEditor,
@@ -130,6 +135,7 @@ import {
onNotificationFilterModeChange, onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor, notificationAddAppColor, notificationRemoveAppColor,
testNotification, testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
} from './features/color-strips.js'; } from './features/color-strips.js';
@@ -138,6 +144,7 @@ import {
showAudioSourceModal, closeAudioSourceModal, saveAudioSource, showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
editAudioSource, cloneAudioSource, deleteAudioSource, editAudioSource, cloneAudioSource, deleteAudioSource,
testAudioSource, closeTestAudioSourceModal, testAudioSource, closeTestAudioSourceModal,
refreshAudioDevices,
} from './features/audio-sources.js'; } from './features/audio-sources.js';
// Layer 5: value sources // Layer 5: value sources
@@ -177,6 +184,11 @@ import { openCommandPalette, closeCommandPalette, initCommandPalette } from './c
import { import {
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
restartServer, saveMqttSettings,
loadApiKeysList,
downloadPartialExport, handlePartialImportFileSelected,
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
loadLogLevel, setLogLevel,
} from './features/settings.js'; } from './features/settings.js';
// ─── Register all HTML onclick / onchange / onfocus globals ─── // ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -240,6 +252,7 @@ Object.assign(window, {
loadDevices, loadDevices,
updateSettingsBaudFpsHint, updateSettingsBaudFpsHint,
copyWsUrl, copyWsUrl,
cloneDevice,
// dashboard // dashboard
loadDashboard, loadDashboard,
@@ -424,6 +437,10 @@ Object.assign(window, {
mappedRemoveZone, mappedRemoveZone,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
onGradientPresetChange,
promptAndSaveGradientPreset,
applyCustomGradientPreset,
deleteAndRefreshGradientPreset,
cloneColorStrip, cloneColorStrip,
toggleCSSOverlay, toggleCSSOverlay,
previewCSSFromEditor, previewCSSFromEditor,
@@ -431,6 +448,7 @@ Object.assign(window, {
onNotificationFilterModeChange, onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor, notificationAddAppColor, notificationRemoveAppColor,
testNotification, testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
// audio sources // audio sources
@@ -442,6 +460,7 @@ Object.assign(window, {
deleteAudioSource, deleteAudioSource,
testAudioSource, testAudioSource,
closeTestAudioSourceModal, closeTestAudioSourceModal,
refreshAudioDevices,
// value sources // value sources
showValueSourceModal, showValueSourceModal,
@@ -504,7 +523,7 @@ Object.assign(window, {
openCommandPalette, openCommandPalette,
closeCommandPalette, closeCommandPalette,
// settings (backup / restore / auto-backup) // settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
openSettingsModal, openSettingsModal,
closeSettingsModal, closeSettingsModal,
downloadBackup, downloadBackup,
@@ -513,6 +532,17 @@ Object.assign(window, {
restoreSavedBackup, restoreSavedBackup,
downloadSavedBackup, downloadSavedBackup,
deleteSavedBackup, deleteSavedBackup,
restartServer,
saveMqttSettings,
loadApiKeysList,
downloadPartialExport,
handlePartialImportFileSelected,
connectLogViewer,
disconnectLogViewer,
clearLogViewer,
applyLogFilter,
loadLogLevel,
setLogLevel,
}); });
// ─── Global keyboard shortcuts ─── // ─── Global keyboard shortcuts ───

View File

@@ -45,8 +45,9 @@ export class CardSection {
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card * @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id') * @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons) * @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
* @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items
*/ */
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible }) { constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey }) {
this.sectionKey = sectionKey; this.sectionKey = sectionKey;
this.titleKey = titleKey; this.titleKey = titleKey;
this.gridClass = gridClass; this.gridClass = gridClass;
@@ -54,6 +55,7 @@ export class CardSection {
this.keyAttr = keyAttr || ''; this.keyAttr = keyAttr || '';
this.headerExtra = headerExtra || ''; this.headerExtra = headerExtra || '';
this.collapsible = !!collapsible; this.collapsible = !!collapsible;
this.emptyKey = emptyKey || '';
this._filterValue = ''; this._filterValue = '';
this._lastItems = null; this._lastItems = null;
this._dragState = null; this._dragState = null;
@@ -85,6 +87,10 @@ export class CardSection {
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>` ? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
: ''; : '';
const emptyState = (count === 0 && this.emptyKey)
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
: '';
return ` return `
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}"> <div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}"> <div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
@@ -99,7 +105,7 @@ export class CardSection {
</div> </div>
</div> </div>
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}> <div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
${cardsHtml} ${emptyState}${cardsHtml}
${addCard} ${addCard}
</div> </div>
</div>`; </div>`;
@@ -205,6 +211,25 @@ export class CardSection {
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 = items.length;
// Show/hide empty state
if (this.emptyKey) {
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
if (items.length === 0) {
if (!emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'cs-empty-state';
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
const addCard = content.querySelector('.cs-add-card');
if (addCard) content.insertBefore(emptyEl, addCard);
else content.appendChild(emptyEl);
}
emptyEl.style.display = '';
} else if (emptyEl) {
emptyEl.style.display = 'none';
}
}
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();

View File

@@ -31,7 +31,7 @@ const PRESETS = [
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) { export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) {
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}" 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>`;
}).join(''); }).join('');
const resetBtn = showReset const resetBtn = showReset

View File

@@ -8,10 +8,11 @@ import { navigateToCard } from './navigation.js';
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_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
} from './icons.js'; } from './icons.js';
import { getCardColor } from './card-colors.js'; import { getCardColor } from './card-colors.js';
import { graphNavigateToNode } from '../features/graph-editor.js'; import { graphNavigateToNode } from '../features/graph-editor.js';
import { showToast } from './ui.js';
let _isOpen = false; let _isOpen = false;
let _items = []; let _items = [];
@@ -33,7 +34,7 @@ function _mapEntities(data, mapFn) {
} }
function _buildItems(results, states = {}) { function _buildItems(results, states = {}) {
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates] = results; const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
const items = []; const items = [];
_mapEntities(devices, d => items.push({ _mapEntities(devices, d => items.push({
@@ -54,6 +55,26 @@ function _buildItems(results, states = {}) {
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running, nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
}); });
} }
// Action items: start or stop
if (running) {
items.push({
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
if (resp.ok) showToast(t('device.stopped'), 'success');
else showToast(t('target.error.stop_failed'), 'error');
},
});
} else {
items.push({
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
if (resp.ok) showToast(t('device.started'), 'success');
else showToast(t('target.error.start_failed'), 'error');
},
});
}
}); });
_mapEntities(css, c => items.push({ _mapEntities(css, c => items.push({
@@ -61,10 +82,31 @@ function _buildItems(results, states = {}) {
nav: ['streams', 'color_strip', 'color-strips', 'data-css-id', c.id], nav: ['streams', 'color_strip', 'color-strips', 'data-css-id', c.id],
})); }));
_mapEntities(automations, a => items.push({ _mapEntities(automations, a => {
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION, items.push({
nav: ['automations', null, 'automations', 'data-automation-id', a.id], name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
})); nav: ['automations', null, 'automations', 'data-automation-id', a.id],
});
if (a.enabled) {
items.push({
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.disable') + ': ' + a.name, 'success');
else showToast(t('search.action.disable') + ' failed', 'error');
},
});
} else {
items.push({
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.enable') + ': ' + a.name, 'success');
else showToast(t('search.action.enable') + ' failed', 'error');
},
});
}
});
_mapEntities(capTempl, ct => items.push({ _mapEntities(capTempl, ct => items.push({
name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: ICON_CAPTURE_TEMPLATE, name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: ICON_CAPTURE_TEMPLATE,
@@ -102,16 +144,31 @@ function _buildItems(results, states = {}) {
}); });
}); });
_mapEntities(scenePresets, sp => items.push({ _mapEntities(scenePresets, sp => {
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE, items.push({
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id], name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
})); nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
});
items.push({
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
action: async () => {
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
if (resp.ok) showToast(t('scenes.activated'), 'success');
else showToast(t('scenes.error.activate_failed'), 'error');
},
});
});
_mapEntities(csptTemplates, ct => items.push({ _mapEntities(csptTemplates, ct => items.push({
name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT, name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT,
nav: ['streams', 'css_processing', 'cspt-templates', 'data-cspt-id', ct.id], nav: ['streams', 'css_processing', 'cspt-templates', 'data-cspt-id', ct.id],
})); }));
_mapEntities(syncClocks, sc => items.push({
name: sc.name, detail: sc.is_running ? 'running' : '', group: 'sync_clocks', icon: ICON_CLOCK,
nav: ['streams', 'sync', 'sync-clocks', 'data-id', sc.id],
}));
return items; return items;
} }
@@ -129,6 +186,7 @@ const _responseKeys = [
['/picture-sources', 'streams'], ['/picture-sources', 'streams'],
['/scene-presets', 'presets'], ['/scene-presets', 'presets'],
['/color-strip-processing-templates', 'templates'], ['/color-strip-processing-templates', 'templates'],
['/sync-clocks', 'clocks'],
]; ];
async function _fetchAllEntities() { async function _fetchAllEntities() {
@@ -149,9 +207,10 @@ async function _fetchAllEntities() {
// ─── Group ordering ─── // ─── Group ordering ───
const _groupOrder = [ const _groupOrder = [
'actions',
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations', 'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
'streams', 'capture_templates', 'pp_templates', 'pattern_templates', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
'audio', 'value', 'scenes', 'audio', 'value', 'scenes', 'sync_clocks',
]; ];
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i])); const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
@@ -204,9 +263,11 @@ function _render() {
html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`; html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`;
for (const item of items) { for (const item of items) {
const active = idx === _selectedIdx ? ' cp-active' : ''; const active = idx === _selectedIdx ? ' cp-active' : '';
const color = getCardColor(item.nav[4]); const entityId = item.nav ? item.nav[4] : null;
const color = entityId ? getCardColor(entityId) : null;
const colorStyle = color ? ` style="border-left:3px solid ${color}"` : ''; const colorStyle = color ? ` style="border-left:3px solid ${color}"` : '';
html += `<div class="cp-result${active}" data-cp-idx="${idx}"${colorStyle}>` + const actionClass = item.action ? ' cp-action-item' : '';
html += `<div class="cp-result${active}${actionClass}" data-cp-idx="${idx}"${colorStyle}>` +
`<span class="cp-icon">${item.icon}</span>` + `<span class="cp-icon">${item.icon}</span>` +
`<span class="cp-name">${escapeHtml(item.name)}</span>` + `<span class="cp-name">${escapeHtml(item.name)}</span>` +
(item.running ? '<span class="cp-running"></span>' : '') + (item.running ? '<span class="cp-running"></span>' : '') +
@@ -307,6 +368,12 @@ function _selectCurrent() {
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return; if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
const item = _filtered[_selectedIdx]; const item = _filtered[_selectedIdx];
closeCommandPalette(); closeCommandPalette();
if (item.action) {
item.action().catch(err => {
if (!err.isAuth) showToast(err.message || 'Action failed', 'error');
});
return;
}
// If graph tab is active, navigate to graph node instead of card // If graph tab is active, navigate to graph node instead of card
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active'); const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
if (graphTabActive) { if (graphTabActive) {

View File

@@ -148,10 +148,11 @@ export class FilterListManager {
if (opt.type === 'bool') { if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true'; const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool"> html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}"> <span class="pp-filter-option-label">${escapeHtml(opt.label)}</span>
<span>${escapeHtml(opt.label)}</span> <label class="settings-toggle" for="${inputId}">
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''} <input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="${updateFn}(${index}, '${opt.key}', this.checked)"> onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
<span class="settings-toggle-slider"></span>
</label> </label>
</div>`; </div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) { } else if (opt.type === 'select' && Array.isArray(opt.choices)) {

View File

@@ -211,6 +211,18 @@ export async function deleteAudioSource(sourceId) {
} }
} }
// ── Refresh devices ───────────────────────────────────────────
export async function refreshAudioDevices() {
const btn = document.getElementById('audio-source-refresh-devices');
if (btn) btn.disabled = true;
try {
await _loadAudioDevices();
} finally {
if (btn) btn.disabled = false;
}
}
// ── Helpers ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
let _cachedDevicesByEngine = {}; let _cachedDevicesByEngine = {};

View File

@@ -42,7 +42,7 @@ class AutomationEditorModal extends Modal {
} }
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' }); const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations' });
/* ── Condition logic IconSelect ───────────────────────────────── */ /* ── Condition logic IconSelect ───────────────────────────────── */

View File

@@ -13,7 +13,7 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_SUN_DIM, ICON_WARNING, ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION,
} from '../core/icons.js'; } from '../core/icons.js';
import * as P from '../core/icon-paths.js'; import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
@@ -25,10 +25,12 @@ import {
rgbArrayToHex, hexToRgbArray, rgbArrayToHex, hexToRgbArray,
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset, gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML, getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
} from './css-gradient-editor.js'; } from './css-gradient-editor.js';
// Re-export for app.js window global bindings // Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset }; export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };
class CSSEditorModal extends Modal { class CSSEditorModal extends Modal {
constructor() { constructor() {
@@ -173,7 +175,7 @@ export function onCSSTypeChange() {
_ensureAudioPaletteIconSelect(); _ensureAudioPaletteIconSelect();
onAudioVizChange(); onAudioVizChange();
} }
if (type === 'gradient') _ensureGradientPresetIconSelect(); if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
if (type === 'notification') { if (type === 'notification') {
_ensureNotificationEffectIconSelect(); _ensureNotificationEffectIconSelect();
_ensureNotificationFilterModeIconSelect(); _ensureNotificationFilterModeIconSelect();
@@ -415,19 +417,64 @@ function _ensureAudioVizIconSelect() {
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 }); _audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 });
} }
function _ensureGradientPresetIconSelect() { function _buildGradientPresetItems() {
const sel = document.getElementById('css-editor-gradient-preset'); const builtIn = [
if (!sel) return;
const items = [
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') }, { value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({ ...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`), value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
})), })),
]; ];
const custom = loadCustomGradientPresets().map(p => ({
value: `__custom__${p.name}`,
icon: gradientPresetStripHTML(p.stops),
label: p.name,
isCustom: true,
}));
return [...builtIn, ...custom];
}
function _ensureGradientPresetIconSelect() {
const sel = document.getElementById('css-editor-gradient-preset');
if (!sel) return;
const items = _buildGradientPresetItems();
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 }); _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
} }
/** Rebuild the preset picker after adding/removing custom presets. */
export function refreshGradientPresetPicker() {
if (_gradientPresetIconSelect) {
_gradientPresetIconSelect.updateItems(_buildGradientPresetItems());
_gradientPresetIconSelect.setValue('');
}
_renderCustomPresetList();
}
/** Render the custom preset list below the save button. */
function _renderCustomPresetList() {
const container = document.getElementById('css-editor-custom-presets-list');
if (!container) return;
const presets = loadCustomGradientPresets();
if (presets.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = presets.map(p => {
const strip = gradientPresetStripHTML(p.stops, 60, 14);
const safeName = escapeHtml(p.name);
return `<div class="custom-preset-row">
${strip}
<span class="custom-preset-name">${safeName}</span>
<button type="button" class="btn btn-icon btn-sm btn-secondary"
onclick="applyCustomGradientPreset(${JSON.stringify(p.name)})"
title="${t('color_strip.gradient.preset.apply')}">&#x2713;</button>
<button type="button" class="btn btn-icon btn-sm btn-danger"
onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
title="${t('common.delete')}">&#x2715;</button>
</div>`;
}).join('');
}
function _ensureNotificationEffectIconSelect() { function _ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect'); const sel = document.getElementById('css-editor-notification-effect');
if (!sel) return; if (!sel) return;
@@ -473,6 +520,40 @@ function _buildAnimationTypeItems(cssType) {
return items; return items;
} }
/** Handles the gradient preset selector change — routes to built-in or custom preset. */
export function onGradientPresetChange(value) {
if (!value) return; // "— Custom —" selected
if (value.startsWith('__custom__')) {
applyCustomGradientPreset(value.slice('__custom__'.length));
} else {
applyGradientPreset(value);
}
}
/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */
export function promptAndSaveGradientPreset() {
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
if (!name || !name.trim()) return;
saveCurrentAsCustomPreset(name.trim());
showToast(t('color_strip.gradient.preset.saved'), 'success');
refreshGradientPresetPicker();
}
/** Apply a custom preset by name. */
export function applyCustomGradientPreset(name) {
const presets = loadCustomGradientPresets();
const preset = presets.find(p => p.name === name);
if (!preset) return;
gradientInit(preset.stops);
}
/** Delete a custom preset and refresh the picker. */
export function deleteAndRefreshGradientPreset(name) {
deleteCustomGradientPreset(name);
showToast(t('color_strip.gradient.preset.deleted'), 'success');
refreshGradientPresetPicker();
}
function _ensureAnimationTypeIconSelect(cssType) { function _ensureAnimationTypeIconSelect(cssType) {
const sel = document.getElementById('css-editor-animation-type'); const sel = document.getElementById('css-editor-animation-type');
if (!sel) return; if (!sel) return;
@@ -650,8 +731,9 @@ function _compositeRenderList() {
).join(''); ).join('');
const canRemove = _compositeLayers.length > 1; const canRemove = _compositeLayers.length > 1;
return ` return `
<div class="composite-layer-item"> <div class="composite-layer-item" data-layer-index="${i}">
<div class="composite-layer-row"> <div class="composite-layer-row">
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select> <select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}"> <select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option> <option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
@@ -737,6 +819,8 @@ function _compositeRenderList() {
noneLabel: t('common.none_no_cspt'), noneLabel: t('common.none_no_cspt'),
})); }));
}); });
_initCompositeLayerDrag(list);
} }
export function compositeAddLayer() { export function compositeAddLayer() {
@@ -780,6 +864,149 @@ function _compositeLayersSyncFromDom() {
} }
} }
/* ── Composite layer drag-to-reorder ── */
const _COMPOSITE_DRAG_THRESHOLD = 5;
let _compositeLayerDragState = null;
function _initCompositeLayerDrag(list) {
// Guard against stacking listeners across re-renders (the list DOM node persists).
if (list._compositeDragBound) return;
list._compositeDragBound = true;
list.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.composite-layer-drag-handle');
if (!handle) return;
const item = handle.closest('.composite-layer-item');
if (!item) return;
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt(item.dataset.layerIndex, 10);
_compositeLayerDragState = {
item,
list,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
offsetY: 0,
fromIndex,
scrollRaf: null,
};
const onMove = (ev) => _onCompositeLayerDragMove(ev);
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
_onCompositeLayerDragEnd();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}, { capture: false });
}
function _onCompositeLayerDragMove(e) {
const ds = _compositeLayerDragState;
if (!ds) return;
if (!ds.started) {
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
_startCompositeLayerDrag(ds, e);
}
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
const items = ds.list.querySelectorAll('.composite-layer-item');
for (const it of items) {
if (it.style.display === 'none') continue;
const r = it.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
if (it === ds.lastTarget && before === ds.lastBefore) break;
ds.lastTarget = it;
ds.lastBefore = before;
if (before) {
ds.list.insertBefore(ds.placeholder, it);
} else {
ds.list.insertBefore(ds.placeholder, it.nextSibling);
}
break;
}
}
// Auto-scroll near modal edges
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const modal = ds.list.closest('.modal-body');
if (modal) {
const EDGE = 60, SPEED = 12;
const mr = modal.getBoundingClientRect();
let speed = 0;
if (e.clientY < mr.top + EDGE) speed = -SPEED;
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
if (speed !== 0) {
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
ds.scrollRaf = requestAnimationFrame(scroll);
}
}
}
function _startCompositeLayerDrag(ds, e) {
ds.started = true;
const rect = ds.item.getBoundingClientRect();
const clone = ds.item.cloneNode(true);
clone.className = ds.item.className + ' composite-layer-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetY = e.clientY - rect.top;
const placeholder = document.createElement('div');
placeholder.className = 'composite-layer-drag-placeholder';
placeholder.style.height = rect.height + 'px';
ds.item.parentNode.insertBefore(placeholder, ds.item);
ds.placeholder = placeholder;
ds.item.style.display = 'none';
document.body.classList.add('composite-layer-dragging');
}
function _onCompositeLayerDragEnd() {
const ds = _compositeLayerDragState;
_compositeLayerDragState = null;
if (!ds || !ds.started) return;
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Determine new index from placeholder position
let toIndex = 0;
for (const child of ds.list.children) {
if (child === ds.placeholder) break;
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
toIndex++;
}
}
// Cleanup DOM
ds.item.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
document.body.classList.remove('composite-layer-dragging');
// Sync current DOM values before reordering
_compositeLayersSyncFromDom();
// Reorder array and re-render
if (toIndex !== ds.fromIndex) {
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
_compositeLayers.splice(toIndex, 0, moved);
_compositeRenderList();
}
}
function _compositeGetLayers() { function _compositeGetLayers() {
_compositeLayersSyncFromDom(); _compositeLayersSyncFromDom();
return _compositeLayers.map(l => { return _compositeLayers.map(l => {
@@ -1074,6 +1301,79 @@ export async function testNotification(sourceId) {
} }
} }
// ── OS Notification History Modal ─────────────────────────────────────────
export function showNotificationHistory() {
const modal = document.getElementById('notification-history-modal');
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
_loadNotificationHistory();
}
export function closeNotificationHistory() {
const modal = document.getElementById('notification-history-modal');
if (modal) modal.style.display = 'none';
}
export async function refreshNotificationHistory() {
await _loadNotificationHistory();
}
async function _loadNotificationHistory() {
const list = document.getElementById('notification-history-list');
const status = document.getElementById('notification-history-status');
if (!list) return;
try {
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.available) {
list.innerHTML = '';
if (status) {
status.textContent = t('color_strip.notification.history.unavailable');
status.style.display = '';
}
return;
}
if (status) status.style.display = 'none';
const history = data.history || [];
if (history.length === 0) {
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
return;
}
list.innerHTML = history.map(entry => {
const appName = entry.app || t('color_strip.notification.history.unknown_app');
const timeStr = new Date(entry.time * 1000).toLocaleString();
const fired = entry.fired ?? 0;
const filtered = entry.filtered ?? 0;
const firedBadge = fired > 0
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
: '';
const filteredBadge = filtered > 0
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
: '';
return `<div class="notif-history-row">
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
<div class="notif-history-time">${timeStr}</div>
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
</div>`;
}).join('');
} catch (err) {
console.error('Failed to load notification history:', err);
if (status) {
status.textContent = t('color_strip.notification.history.error');
status.style.display = '';
}
list.innerHTML = '';
}
}
function _notificationAppColorsSyncFromDom() { function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list'); const list = document.getElementById('notification-app-colors-list');
if (!list) return; if (!list) return;
@@ -1353,6 +1653,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const testNotifyBtn = isNotification const testNotifyBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>` ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
: ''; : '';
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const testPreviewBtn = !isApiInput const testPreviewBtn = !isApiInput
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>` ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
: ''; : '';
@@ -1375,7 +1678,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
actions: ` actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> <button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button> <button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${testNotifyBtn}${testPreviewBtn}`, ${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
}); });
} }

View File

@@ -378,6 +378,41 @@ function _gradientStartDrag(e, idx) {
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
} }
/* ── Custom presets (localStorage) ───────────────────────────── */
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
export function loadCustomGradientPresets() {
try {
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
} catch {
return [];
}
}
/** Save the current gradient stops as a named custom preset. */
export function saveCurrentAsCustomPreset(name) {
if (!name) return;
const stops = _gradientStops.map(s => ({
position: s.position,
color: [...s.color],
...(s.colorRight ? { color_right: [...s.colorRight] } : {}),
}));
const presets = loadCustomGradientPresets();
// Replace if same name exists
const idx = presets.findIndex(p => p.name === name);
if (idx >= 0) presets[idx] = { name, stops };
else presets.push({ name, stops });
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
}
/** Delete a custom preset by name. */
export function deleteCustomGradientPreset(name) {
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
}
/* ── Track click → add stop ───────────────────────────────────── */ /* ── Track click → add stop ───────────────────────────────────── */
function _gradientSetupTrackClick() { function _gradientSetupTrackClick() {

View File

@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js'; import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
@@ -733,6 +733,8 @@ export async function dashboardStopTarget(targetId) {
} }
export async function dashboardStopAll() { export async function dashboardStopAll() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
try { try {
const [allTargets, statesResp] = await Promise.all([ const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []), outputTargetsCache.fetch().catch(() => []),

View File

@@ -582,7 +582,7 @@ export function onSerialPortFocus() {
} }
} }
export function showAddDevice(presetType = null) { export function showAddDevice(presetType = null, cloneData = null) {
// When no type specified: show type picker first // When no type specified: show type picker first
if (!presetType) { if (!presetType) {
showTypePicker({ showTypePicker({
@@ -623,6 +623,47 @@ export function showAddDevice(presetType = null) {
addDeviceModal.open(); addDeviceModal.open();
onDeviceTypeChanged(); onDeviceTypeChanged();
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
if (cloneData) {
document.getElementById('device-name').value = (cloneData.name || '') + ' (Copy)';
// Clear URL — devices must have unique addresses, user must enter a new one
const urlInput = document.getElementById('device-url');
if (urlInput) urlInput.value = '';
// Prefill LED count
const ledCountInput = document.getElementById('device-led-count');
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
// Prefill baud rate for serial devices
if (isSerialDevice(presetType)) {
const baudSelect = document.getElementById('device-baud-rate');
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
}
// Prefill mock device fields
if (isMockDevice(presetType)) {
const ledTypeEl = document.getElementById('device-led-type');
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
const sendLatencyEl = document.getElementById('device-send-latency');
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
}
// Prefill DMX fields
if (isDmxDevice(presetType)) {
const dmxProto = document.getElementById('device-dmx-protocol');
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
const dmxUniverse = document.getElementById('device-dmx-start-universe');
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
const dmxChannel = document.getElementById('device-dmx-start-channel');
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
}
// Prefill CSPT template selector (after fetch completes)
if (cloneData.default_css_processing_template_id) {
csptCache.fetch().then(() => {
_ensureCsptEntitySelect();
const csptEl = document.getElementById('device-css-processing-template');
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
});
}
}
setTimeout(() => { setTimeout(() => {
desktopFocus(document.getElementById('device-name')); desktopFocus(document.getElementById('device-name'));
addDeviceModal.snapshot(); addDeviceModal.snapshot();
@@ -984,3 +1025,18 @@ function _showGameSenseFields(show) {
const el = document.getElementById('device-gamesense-device-type-group'); const el = document.getElementById('device-gamesense-device-type-group');
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
} }
/* ── Clone device ──────────────────────────────────────────────── */
export async function cloneDevice(deviceId) {
try {
const resp = await fetchWithAuth(`/devices/${deviceId}`);
if (!resp.ok) throw new Error('Failed to load device');
const device = await resp.json();
showAddDevice(device.device_type || 'wled', device);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone device:', error);
showToast(t('device.error.clone_failed'), 'error');
}
}

View File

@@ -12,7 +12,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
@@ -95,6 +95,22 @@ class DeviceSettingsModal extends Modal {
const settingsModal = new DeviceSettingsModal(); const settingsModal = new DeviceSettingsModal();
function _formatRelativeTime(isoString) {
if (!isoString) return null;
const then = new Date(isoString);
const diffMs = Date.now() - then.getTime();
if (diffMs < 0) return null;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 5) return t('device.last_seen.just_now');
if (diffSec < 60) return t('device.last_seen.seconds').replace('%d', diffSec);
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t('device.last_seen.minutes').replace('%d', diffMin);
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return t('device.last_seen.hours').replace('%d', diffHr);
const diffDay = Math.floor(diffHr / 24);
return t('device.last_seen.days').replace('%d', diffDay);
}
export function createDeviceCard(device) { export function createDeviceCard(device) {
const state = device.state || {}; const state = device.state || {};
@@ -124,6 +140,7 @@ export function createDeviceCard(device) {
} }
const ledCount = state.device_led_count || device.led_count; const ledCount = state.device_led_count || device.led_count;
const lastSeenLabel = devLastChecked ? _formatRelativeTime(devLastChecked) : null;
// Parse zone names from OpenRGB URL for badge display // Parse zone names from OpenRGB URL for badge display
const openrgbZones = isOpenrgbDevice(device.device_type) const openrgbZones = isOpenrgbDevice(device.device_type)
@@ -152,6 +169,7 @@ export function createDeviceCard(device) {
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''} ${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span> <span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div> </div>
${lastSeenLabel ? `<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" title="${devLastChecked}">⏱ ${t('device.last_seen.label')}: ${lastSeenLabel}</span></div>` : ''}
${(device.capabilities || []).includes('brightness_control') ? ` ${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}"> <div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255" <input type="range" class="brightness-slider" min="0" max="255"
@@ -166,6 +184,9 @@ export function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}"> <button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
${ICON_REFRESH} ${ICON_REFRESH}
</button> </button>
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}"> <button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS} ${ICON_SETTINGS}
</button>`, </button>`,
@@ -173,6 +194,8 @@ export function createDeviceCard(device) {
} }
export async function turnOffDevice(deviceId) { export async function turnOffDevice(deviceId) {
const confirmed = await showConfirm(t('confirm.turn_off_device'));
if (!confirmed) return;
try { try {
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, { const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
method: 'PUT', method: 'PUT',

View File

@@ -11,7 +11,7 @@ import { CardSection } from '../core/card-sections.js';
import { import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
} from '../core/icons.js'; } from '../core/icons.js';
import { scenePresetsCache, outputTargetsCache } from '../core/state.js'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
import { cardColorStyle, cardColorButton } from '../core/card-colors.js'; import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
import { EntityPalette } from '../core/entity-palette.js'; import { EntityPalette } from '../core/entity-palette.js';
@@ -43,13 +43,18 @@ export const csScenes = new CardSection('scenes', {
gridClass: 'devices-grid', gridClass: 'devices-grid',
addCardOnclick: "openScenePresetCapture()", addCardOnclick: "openScenePresetCapture()",
keyAttr: 'data-scene-id', keyAttr: 'data-scene-id',
emptyKey: 'section.empty.scenes',
}); });
export function createSceneCard(preset) { export function createSceneCard(preset) {
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const automations = automationsCacheObj.data || [];
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
const meta = [ const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
usedByCount > 0 ? `🔗 ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
].filter(Boolean); ].filter(Boolean);
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';

View File

@@ -9,17 +9,139 @@ import { showToast, showConfirm } from '../core/ui.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
// ─── Log Viewer ────────────────────────────────────────────
/** @type {WebSocket|null} */
let _logWs = null;
/** Level ordering for filter comparisons */
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
function _detectLevel(line) {
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
if (line.includes(lvl)) return lvl;
}
return 'DEBUG';
}
function _levelClass(level) {
if (level === 'ERROR' || level === 'CRITICAL') return 'log-line-error';
if (level === 'WARNING') return 'log-line-warning';
if (level === 'DEBUG') return 'log-line-debug';
return '';
}
function _filterLevel() {
const sel = document.getElementById('log-viewer-filter');
return sel ? sel.value : 'all';
}
function _linePassesFilter(line) {
const filter = _filterLevel();
if (filter === 'all') return true;
const lineLvl = _detectLevel(line);
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
}
function _appendLine(line) {
// Skip keepalive empty pings
if (!line) return;
if (!_linePassesFilter(line)) return;
const output = document.getElementById('log-viewer-output');
if (!output) return;
const level = _detectLevel(line);
const cls = _levelClass(level);
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = line + '\n';
output.appendChild(span);
// Auto-scroll to bottom
output.scrollTop = output.scrollHeight;
}
export function connectLogViewer() {
const btn = document.getElementById('log-viewer-connect-btn');
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
// Disconnect
_logWs.close();
_logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/v1/system/logs/ws?token=${encodeURIComponent(apiKey)}`;
_logWs = new WebSocket(url);
_logWs.onopen = () => {
if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; }
};
_logWs.onmessage = (evt) => {
_appendLine(evt.data);
};
_logWs.onerror = () => {
showToast(t('settings.logs.error'), 'error');
};
_logWs.onclose = () => {
_logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
};
}
export function disconnectLogViewer() {
if (_logWs) {
_logWs.close();
_logWs = null;
}
const btn = document.getElementById('log-viewer-connect-btn');
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
}
export function clearLogViewer() {
const output = document.getElementById('log-viewer-output');
if (output) output.innerHTML = '';
}
/** Re-render the log output according to the current filter selection. */
export function applyLogFilter() {
// We don't buffer all raw lines in JS — just clear and note the filter
// will apply to future lines. Existing lines that were already rendered
// are re-evaluated by toggling their visibility.
const output = document.getElementById('log-viewer-output');
if (!output) return;
const filter = _filterLevel();
for (const span of output.children) {
const line = span.textContent;
const lineLvl = _detectLevel(line);
const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
span.style.display = passes ? '' : 'none';
}
}
// Simple modal (no form / no dirty check needed) // Simple modal (no form / no dirty check needed)
const settingsModal = new Modal('settings-modal'); const settingsModal = new Modal('settings-modal');
export function openSettingsModal() { export function openSettingsModal() {
document.getElementById('settings-error').style.display = 'none'; document.getElementById('settings-error').style.display = 'none';
settingsModal.open(); settingsModal.open();
loadApiKeysList();
loadAutoBackupSettings(); loadAutoBackupSettings();
loadBackupList(); loadBackupList();
loadMqttSettings();
loadLogLevel();
} }
export function closeSettingsModal() { export function closeSettingsModal() {
disconnectLogViewer();
settingsModal.forceClose(); settingsModal.forceClose();
} }
@@ -90,9 +212,30 @@ export async function handleRestoreFileSelected(input) {
} }
} }
// ─── Server restart ────────────────────────────────────────
export async function restartServer() {
const confirmed = await showConfirm(t('settings.restart_confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth('/system/restart', { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
settingsModal.forceClose();
showRestartOverlay(t('settings.restarting'));
} catch (err) {
console.error('Server restart failed:', err);
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
}
}
// ─── Restart overlay ─────────────────────────────────────── // ─── Restart overlay ───────────────────────────────────────
function showRestartOverlay() { function showRestartOverlay(message) {
const msg = message || t('settings.restore.restarting');
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'restart-overlay'; overlay.id = 'restart-overlay';
overlay.style.cssText = overlay.style.cssText =
@@ -101,7 +244,7 @@ function showRestartOverlay() {
overlay.innerHTML = overlay.innerHTML =
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' + '<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' + 'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`; `<div id="restart-msg">${msg}</div>`;
// Add spinner animation if not present // Add spinner animation if not present
if (!document.getElementById('restart-spinner-style')) { if (!document.getElementById('restart-spinner-style')) {
@@ -201,12 +344,20 @@ export async function loadBackupList() {
} }
container.innerHTML = data.backups.map(b => { container.innerHTML = data.backups.map(b => {
const sizeKB = (b.size_bytes / 1024).toFixed(1); const sizeBytes = b.size_bytes || 0;
const sizeStr = sizeBytes >= 1024 * 1024
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
: (sizeBytes / 1024).toFixed(1) + ' KB';
const date = new Date(b.created_at).toLocaleString(); const date = new Date(b.created_at).toLocaleString();
const isAuto = b.filename.startsWith('ledgrab-autobackup-');
const typeBadge = isAuto
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>`
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`;
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;"> return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
${typeBadge}
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}"> <div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
<span>${date}</span> <span>${date}</span>
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span> <span style="color:var(--text-muted);margin-left:0.3rem;">${sizeStr}</span>
</div> </div>
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button> <button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button>
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button> <button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button>
@@ -299,3 +450,192 @@ export async function deleteSavedBackup(filename) {
showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error'); showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error');
} }
} }
// ─── API Keys (read-only display) ─────────────────────────────
export async function loadApiKeysList() {
const container = document.getElementById('settings-api-keys-list');
if (!container) return;
try {
const resp = await fetchWithAuth('/system/api-keys');
if (!resp.ok) {
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`;
return;
}
const data = await resp.json();
if (data.count === 0) {
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.empty')}</div>`;
return;
}
container.innerHTML = data.keys.map(k =>
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);">
<span style="font-weight:600;min-width:80px;">${k.label}</span>
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code>
</div>`
).join('');
} catch (err) {
console.error('Failed to load API keys:', err);
if (container) container.innerHTML = '';
}
}
// ─── Partial Export / Import ───────────────────────────────────
export async function downloadPartialExport() {
const storeKey = document.getElementById('settings-partial-store').value;
try {
const resp = await fetchWithAuth(`/system/export/${encodeURIComponent(storeKey)}`, { timeout: 30000 });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const disposition = resp.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="(.+?)"/);
const filename = match ? match[1] : `ledgrab-${storeKey}.json`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
showToast(t('settings.partial.export_success'), 'success');
} catch (err) {
console.error('Partial export failed:', err);
showToast(t('settings.partial.export_error') + ': ' + err.message, 'error');
}
}
export async function handlePartialImportFileSelected(input) {
const file = input.files[0];
input.value = '';
if (!file) return;
const storeKey = document.getElementById('settings-partial-store').value;
const merge = document.getElementById('settings-partial-merge').checked;
const confirmMsg = merge
? t('settings.partial.import_confirm_merge').replace('{store}', storeKey)
: t('settings.partial.import_confirm_replace').replace('{store}', storeKey);
const confirmed = await showConfirm(confirmMsg);
if (!confirmed) return;
try {
const formData = new FormData();
formData.append('file', file);
const url = `${API_BASE}/system/import/${encodeURIComponent(storeKey)}?merge=${merge}`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: formData,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
showToast(data.message || t('settings.partial.import_success'), 'success');
settingsModal.forceClose();
if (data.restart_scheduled) {
showRestartOverlay();
}
} catch (err) {
console.error('Partial import failed:', err);
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
}
}
// ─── Log Level ────────────────────────────────────────────────
export async function loadLogLevel() {
try {
const resp = await fetchWithAuth('/system/log-level');
if (!resp.ok) return;
const data = await resp.json();
const select = document.getElementById('settings-log-level');
if (select) select.value = data.level;
} catch (err) {
console.error('Failed to load log level:', err);
}
}
export async function setLogLevel() {
const select = document.getElementById('settings-log-level');
if (!select) return;
const level = select.value;
try {
const resp = await fetchWithAuth('/system/log-level', {
method: 'PUT',
body: JSON.stringify({ level }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.log_level.saved'), 'success');
} catch (err) {
console.error('Failed to set log level:', err);
showToast(t('settings.log_level.save_error') + ': ' + err.message, 'error');
}
}
// ─── MQTT settings ────────────────────────────────────────────
export async function loadMqttSettings() {
try {
const resp = await fetchWithAuth('/system/mqtt/settings');
if (!resp.ok) return;
const data = await resp.json();
document.getElementById('mqtt-enabled').checked = data.enabled;
document.getElementById('mqtt-host').value = data.broker_host;
document.getElementById('mqtt-port').value = data.broker_port;
document.getElementById('mqtt-username').value = data.username;
document.getElementById('mqtt-password').value = '';
document.getElementById('mqtt-client-id').value = data.client_id;
document.getElementById('mqtt-base-topic').value = data.base_topic;
const hint = document.getElementById('mqtt-password-hint');
if (hint) hint.style.display = data.password_set ? '' : 'none';
} catch (err) {
console.error('Failed to load MQTT settings:', err);
}
}
export async function saveMqttSettings() {
const enabled = document.getElementById('mqtt-enabled').checked;
const broker_host = document.getElementById('mqtt-host').value.trim();
const broker_port = parseInt(document.getElementById('mqtt-port').value, 10);
const username = document.getElementById('mqtt-username').value;
const password = document.getElementById('mqtt-password').value;
const client_id = document.getElementById('mqtt-client-id').value.trim();
const base_topic = document.getElementById('mqtt-base-topic').value.trim();
if (!broker_host) {
showToast(t('settings.mqtt.error_host_required'), 'error');
return;
}
try {
const resp = await fetchWithAuth('/system/mqtt/settings', {
method: 'PUT',
body: JSON.stringify({ enabled, broker_host, broker_port, username, password, client_id, base_topic }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.mqtt.saved'), 'success');
loadMqttSettings();
} catch (err) {
console.error('Failed to save MQTT settings:', err);
showToast(t('settings.mqtt.save_error') + ': ' + err.message, 'error');
}
}

View File

@@ -41,7 +41,7 @@ import {
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js'; import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { TreeNav } from '../core/tree-nav.js'; import { TreeNav } from '../core/tree-nav.js';
@@ -70,19 +70,19 @@ let _audioTemplateTagsInput = null;
let _csptTagsInput = null; let _csptTagsInput = null;
// ── Card section instances ── // ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' }); const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' }); const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' }); const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' }); const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id' }); const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates' });
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' }); const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' });
// Re-render picture sources when language changes // Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -322,6 +322,7 @@ export async function showTestTemplateModal(templateId) {
restoreCaptureDuration(); restoreCaptureDuration();
testTemplateModal.open(); testTemplateModal.open();
setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal());
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('templates.error.load'), 'error'); showToast(t('templates.error.load'), 'error');
@@ -2162,6 +2163,7 @@ export async function showTestStreamModal(streamId) {
restoreStreamTestDuration(); restoreStreamTestDuration();
testStreamModal.open(); testStreamModal.open();
setupBackdropClose(testStreamModal.el, () => closeTestStreamModal());
} }
export function closeTestStreamModal() { export function closeTestStreamModal() {
@@ -2229,6 +2231,7 @@ export async function showTestPPTemplateModal(templateId) {
}); });
testPPTemplateModal.open(); testPPTemplateModal.open();
setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal());
} }
export function closeTestPPTemplateModal() { export function closeTestPPTemplateModal() {

View File

@@ -190,6 +190,15 @@ export async function resetSyncClock(clockId) {
// ── Card rendering ── // ── Card rendering ──
function _formatElapsed(seconds) {
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
export function createSyncClockCard(clock) { export function createSyncClockCard(clock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE; const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused'); const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
@@ -197,6 +206,7 @@ export function createSyncClockCard(clock) {
? `pauseSyncClock('${clock.id}')` ? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`; : `resumeSyncClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
return wrapCard({ return wrapCard({
type: 'template-card', type: 'template-card',
@@ -211,6 +221,7 @@ export function createSyncClockCard(clock) {
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span> <span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span> <span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
${elapsedLabel ? `<span class="stream-card-prop" title="${t('sync_clock.elapsed')}">⏱ ${elapsedLabel}</span>` : ''}
</div> </div>
${renderTagChips(clock.tags)} ${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`, ${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,

View File

@@ -40,10 +40,10 @@ import { updateSubTabHash, updateTabBadge } from './tabs.js';
// (pattern-templates.js calls window.loadTargetsTab) // (pattern-templates.js calls window.loadTargetsTab)
// ── Card section instances ── // ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' }); const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` }); const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' }); const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' });
// Re-render targets tab when language changes (only if tab is active) // Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {
@@ -189,8 +189,12 @@ function _updateSpecificSettingsVisibility() {
const deviceSelect = document.getElementById('target-editor-device'); const deviceSelect = document.getElementById('target-editor-device');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const isWled = !selectedDevice || selectedDevice.device_type === 'wled'; const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
// Hide entire Specific Settings section for non-WLED devices (protocol + keepalive are WLED-only) // Hide WLED-only controls (protocol + keepalive) for non-WLED devices
document.getElementById('target-editor-device-settings').style.display = isWled ? '' : 'none'; const protocolGroup = document.getElementById('target-editor-protocol-group');
if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none';
// keepalive is controlled further by _updateKeepaliveVisibility
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none';
} }
function _updateBrightnessThresholdVisibility() { function _updateBrightnessThresholdVisibility() {
@@ -1069,10 +1073,14 @@ export async function stopTargetProcessing(targetId) {
} }
export async function stopAllLedTargets() { export async function stopAllLedTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('led'); await _stopAllByType('led');
} }
export async function stopAllKCTargets() { export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors'); await _stopAllByType('key_colors');
} }

View File

@@ -123,7 +123,7 @@ export function startCalibrationTutorial() {
if (!container) return; if (!container) return;
startTutorial({ startTutorial({
steps: calibrationTutorialSteps, steps: calibrationTutorialSteps,
overlayId: 'tutorial-overlay', overlayId: 'calibration-tutorial-overlay',
mode: 'absolute', mode: 'absolute',
container: container, container: container,
resolveTarget: (step) => { resolveTarget: (step) => {

View File

@@ -134,6 +134,88 @@ function _ensureWaveformIconSelect() {
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 }); _waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
} }
/* ── Waveform canvas preview ──────────────────────────────────── */
/**
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
* Shows one full cycle of the selected waveform shape.
*/
function _drawWaveformPreview(waveformType) {
const canvas = document.getElementById('value-source-waveform-preview');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 200;
const cssH = 60;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const W = cssW;
const H = cssH;
const padX = 8;
const padY = 8;
const drawW = W - padX * 2;
const drawH = H - padY * 2;
const midY = padY + drawH / 2;
// Draw zero line
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(padX, midY);
ctx.lineTo(padX + drawW, midY);
ctx.stroke();
ctx.setLineDash([]);
// Draw waveform
const N = 120;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const t = i / N; // 0..1 over one cycle
let v; // -1..1
switch (waveformType) {
case 'triangle':
v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
break;
case 'square':
v = t < 0.5 ? 1 : -1;
break;
case 'sawtooth':
v = 2 * t - 1;
break;
case 'sine':
default:
v = Math.sin(2 * Math.PI * t);
break;
}
const x = padX + t * drawW;
const y = midY - v * (drawH / 2);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Glow effect: draw thick translucent line first
ctx.strokeStyle = 'rgba(99,179,237,0.25)';
ctx.lineWidth = 4;
ctx.stroke();
// Crisp line on top
ctx.strokeStyle = '#63b3ed';
ctx.lineWidth = 1.5;
ctx.stroke();
}
export function updateWaveformPreview() {
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
_drawWaveformPreview(wf);
}
/* ── Audio mode icon-grid selector ────────────────────────────── */ /* ── Audio mode icon-grid selector ────────────────────────────── */
const _AUDIO_MODE_SVG = { const _AUDIO_MODE_SVG = {
@@ -208,6 +290,7 @@ export async function showValueSourceModal(editData, presetType = null) {
} else if (editData.source_type === 'animated') { } else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine'; document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine'); if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
_drawWaveformPreview(editData.waveform || 'sine');
_setSlider('value-source-speed', editData.speed ?? 10); _setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0); _setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1); _setSlider('value-source-max-value', editData.max_value ?? 1);
@@ -249,6 +332,7 @@ export async function showValueSourceModal(editData, presetType = null) {
_setSlider('value-source-min-value', 0); _setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1); _setSlider('value-source-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine'; document.getElementById('value-source-waveform').value = 'sine';
_drawWaveformPreview('sine');
_populateAudioSourceDropdown(''); _populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms'; document.getElementById('value-source-mode').value = 'rms';
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms'); if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
@@ -274,7 +358,7 @@ export async function showValueSourceModal(editData, presetType = null) {
} }
// Wire up auto-name triggers // Wire up auto-name triggers
document.getElementById('value-source-waveform').onchange = () => _autoGenerateVSName(); document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName(); document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName(); document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
@@ -296,7 +380,7 @@ export function onValueSourceTypeChange() {
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type); if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none'; document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none'; document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
if (type === 'animated') _ensureWaveformIconSelect(); if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none'; document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'audio') _ensureAudioModeIconSelect(); if (type === 'audio') _ensureAudioModeIconSelect();
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none'; document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';

View File

@@ -29,6 +29,7 @@
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "Enter your API key:", "auth.prompt_enter": "Enter your API key:",
"auth.toggle_password": "Toggle password visibility", "auth.toggle_password": "Toggle password visibility",
"api_key.login": "Login",
"displays.title": "Available Displays", "displays.title": "Available Displays",
"displays.layout": "Displays", "displays.layout": "Displays",
"displays.information": "Display Information", "displays.information": "Display Information",
@@ -291,6 +292,12 @@
"device.health.offline": "Offline", "device.health.offline": "Offline",
"device.health.streaming_unreachable": "Unreachable during streaming", "device.health.streaming_unreachable": "Unreachable during streaming",
"device.health.checking": "Checking...", "device.health.checking": "Checking...",
"device.last_seen.label": "Last seen",
"device.last_seen.just_now": "just now",
"device.last_seen.seconds": "%ds ago",
"device.last_seen.minutes": "%dm ago",
"device.last_seen.hours": "%dh ago",
"device.last_seen.days": "%dd ago",
"device.tutorial.start": "Start tutorial", "device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device", "device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness", "device.tip.brightness": "Slide to adjust device brightness",
@@ -405,6 +412,8 @@
"confirm.title": "Confirm Action", "confirm.title": "Confirm Action",
"confirm.yes": "Yes", "confirm.yes": "Yes",
"confirm.no": "No", "confirm.no": "No",
"confirm.stop_all": "Stop all running targets?",
"confirm.turn_off_device": "Turn off this device?",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.delete": "Delete", "common.delete": "Delete",
"common.edit": "Edit", "common.edit": "Edit",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "Color Strip Sources", "targets.section.color_strips": "Color Strip Sources",
"targets.section.targets": "Targets", "targets.section.targets": "Targets",
"targets.section.specific_settings": "Specific Settings", "targets.section.specific_settings": "Specific Settings",
"targets.section.advanced": "Advanced",
"targets.add": "Add Target", "targets.add": "Add Target",
"targets.edit": "Edit Target", "targets.edit": "Edit Target",
"targets.loading": "Loading targets...", "targets.loading": "Loading targets...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "Cool", "color_strip.gradient.preset.cool": "Cool",
"color_strip.gradient.preset.neon": "Neon", "color_strip.gradient.preset.neon": "Neon",
"color_strip.gradient.preset.pastel": "Pastel", "color_strip.gradient.preset.pastel": "Pastel",
"color_strip.gradient.preset.save_button": "Save as preset…",
"color_strip.gradient.preset.save_prompt": "Enter a name for this preset:",
"color_strip.gradient.preset.saved": "Preset saved",
"color_strip.gradient.preset.deleted": "Preset deleted",
"color_strip.gradient.preset.apply": "Apply",
"color_strip.animation": "Animation", "color_strip.animation": "Animation",
"color_strip.animation.type": "Effect:", "color_strip.animation.type": "Effect:",
"color_strip.animation.type.hint": "Animation effect to apply.", "color_strip.animation.type.hint": "Animation effect to apply.",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "Notification sent", "color_strip.notification.test.ok": "Notification sent",
"color_strip.notification.test.no_streams": "No running streams for this source", "color_strip.notification.test.no_streams": "No running streams for this source",
"color_strip.notification.test.error": "Failed to send notification", "color_strip.notification.test.error": "Failed to send notification",
"color_strip.notification.history.title": "Notification History",
"color_strip.notification.history.hint": "Recent OS notifications captured by the listener (newest first). Up to 50 entries.",
"color_strip.notification.history.empty": "No notifications captured yet",
"color_strip.notification.history.unavailable": "OS notification listener is not available on this platform",
"color_strip.notification.history.error": "Failed to load notification history",
"color_strip.notification.history.refresh": "Refresh",
"color_strip.notification.history.unknown_app": "Unknown app",
"color_strip.notification.history.fired": "Streams triggered",
"color_strip.notification.history.filtered": "Streams filtered",
"color_strip.test.title": "Test Preview", "color_strip.test.title": "Test Preview",
"color_strip.test.connecting": "Connecting...", "color_strip.test.connecting": "Connecting...",
"color_strip.test.error": "Failed to connect to preview stream", "color_strip.test.error": "Failed to connect to preview stream",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "Mono", "audio_source.type.mono": "Mono",
"audio_source.device": "Audio Device:", "audio_source.device": "Audio Device:",
"audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", "audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
"audio_source.refresh_devices": "Refresh devices",
"audio_source.parent": "Parent Source:", "audio_source.parent": "Parent Source:",
"audio_source.parent.hint": "Multichannel source to extract a channel from", "audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:", "audio_source.channel": "Channel:",
@@ -1375,6 +1400,13 @@
"search.group.value": "Value Sources", "search.group.value": "Value Sources",
"search.group.scenes": "Scene Presets", "search.group.scenes": "Scene Presets",
"search.group.cspt": "Strip Processing Templates", "search.group.cspt": "Strip Processing Templates",
"search.group.sync_clocks": "Sync Clocks",
"search.group.actions": "Actions",
"search.action.start": "Start",
"search.action.stop": "Stop",
"search.action.activate": "Activate",
"search.action.enable": "Enable",
"search.action.disable": "Disable",
"settings.backup.label": "Backup Configuration", "settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
"settings.backup.button": "Download Backup", "settings.backup.button": "Download Backup",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "Restore failed", "settings.restore.error": "Restore failed",
"settings.restore.restarting": "Server is restarting...", "settings.restore.restarting": "Server is restarting...",
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.", "settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
"settings.restart_server": "Restart Server",
"settings.restart_confirm": "Restart the server? Active targets will be stopped.",
"settings.restarting": "Restarting server...",
"settings.button.close": "Close", "settings.button.close": "Close",
"settings.log_level.label": "Log Level",
"settings.log_level.hint": "Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.",
"settings.log_level.save": "Apply",
"settings.log_level.saved": "Log level changed",
"settings.log_level.save_error": "Failed to change log level",
"settings.auto_backup.label": "Auto-Backup", "settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup", "settings.auto_backup.enable": "Enable auto-backup",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "Delete", "settings.saved_backups.delete": "Delete",
"settings.saved_backups.delete_confirm": "Delete this backup file?", "settings.saved_backups.delete_confirm": "Delete this backup file?",
"settings.saved_backups.delete_error": "Failed to delete backup", "settings.saved_backups.delete_error": "Failed to delete backup",
"settings.saved_backups.type.auto": "auto",
"settings.saved_backups.type.manual": "manual",
"settings.mqtt.label": "MQTT",
"settings.mqtt.hint": "Configure MQTT broker connection for automation conditions and triggers.",
"settings.mqtt.enabled": "Enable MQTT",
"settings.mqtt.host_label": "Broker Host",
"settings.mqtt.port_label": "Port",
"settings.mqtt.username_label": "Username",
"settings.mqtt.password_label": "Password",
"settings.mqtt.password_set_hint": "Password is set — leave blank to keep",
"settings.mqtt.client_id_label": "Client ID",
"settings.mqtt.base_topic_label": "Base Topic",
"settings.mqtt.save": "Save MQTT Settings",
"settings.mqtt.saved": "MQTT settings saved",
"settings.mqtt.save_error": "Failed to save MQTT settings",
"settings.mqtt.error_host_required": "Broker host is required",
"settings.logs.label": "Server Logs",
"settings.logs.hint": "Stream live server log output. Use the filter to show only relevant log levels.",
"settings.logs.connect": "Connect",
"settings.logs.disconnect": "Disconnect",
"settings.logs.clear": "Clear",
"settings.logs.error": "Log viewer connection failed",
"settings.logs.filter.all": "All levels",
"settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "Error only",
"device.error.power_off_failed": "Failed to turn off device", "device.error.power_off_failed": "Failed to turn off device",
"device.removed": "Device removed", "device.removed": "Device removed",
"device.error.remove_failed": "Failed to remove device", "device.error.remove_failed": "Failed to remove device",
@@ -1415,6 +1481,7 @@
"device.error.required": "Please fill in all fields correctly", "device.error.required": "Please fill in all fields correctly",
"device.error.update": "Failed to update device", "device.error.update": "Failed to update device",
"device.error.save": "Failed to save settings", "device.error.save": "Failed to save settings",
"device.error.clone_failed": "Failed to clone device",
"device_discovery.error.fill_all_fields": "Please fill in all fields", "device_discovery.error.fill_all_fields": "Please fill in all fields",
"device_discovery.added": "Device added successfully", "device_discovery.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device", "device_discovery.error.add_failed": "Failed to add device",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "Clock resumed", "sync_clock.resumed": "Clock resumed",
"sync_clock.reset_done": "Clock reset to zero", "sync_clock.reset_done": "Clock reset to zero",
"sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.", "sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.",
"sync_clock.elapsed": "Elapsed time",
"color_strip.clock": "Sync Clock:", "color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph", "graph.title": "Graph",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "Detach connection", "graph.help.right_click_desc": "Detach connection",
"automation.enabled": "Automation enabled", "automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled", "automation.disabled": "Automation disabled",
"scene_preset.activated": "Preset activated" "scene_preset.activated": "Preset activated",
"scene_preset.used_by": "Used by %d automation(s)",
"settings.api_keys.label": "API Keys",
"settings.api_keys.hint": "API keys are defined in the server config file (config.yaml). Edit the file and restart the server to apply changes.",
"settings.api_keys.empty": "No API keys configured",
"settings.api_keys.load_error": "Failed to load API keys",
"settings.partial.label": "Partial Export / Import",
"settings.partial.hint": "Export or import a single entity type. Import replaces or merges existing data and restarts the server.",
"settings.partial.store.devices": "Devices",
"settings.partial.store.output_targets": "LED Targets",
"settings.partial.store.color_strip_sources": "Color Strips",
"settings.partial.store.picture_sources": "Picture Sources",
"settings.partial.store.audio_sources": "Audio Sources",
"settings.partial.store.audio_templates": "Audio Templates",
"settings.partial.store.capture_templates": "Capture Templates",
"settings.partial.store.postprocessing_templates": "Post-processing Templates",
"settings.partial.store.color_strip_processing_templates": "CSS Processing Templates",
"settings.partial.store.pattern_templates": "Pattern Templates",
"settings.partial.store.value_sources": "Value Sources",
"settings.partial.store.sync_clocks": "Sync Clocks",
"settings.partial.store.automations": "Automations",
"settings.partial.store.scene_presets": "Scene Presets",
"settings.partial.export_button": "Export",
"settings.partial.import_button": "Import from File",
"settings.partial.merge_label": "Merge (add/overwrite, keep existing)",
"settings.partial.export_success": "Exported successfully",
"settings.partial.export_error": "Export failed",
"settings.partial.import_success": "Imported successfully",
"settings.partial.import_error": "Import failed",
"settings.partial.import_confirm_replace": "This will REPLACE all {store} data and restart the server. Continue?",
"settings.partial.import_confirm_merge": "This will MERGE into existing {store} data and restart the server. Continue?",
"section.empty.devices": "No devices yet. Click + to add one.",
"section.empty.targets": "No LED targets yet. Click + to add one.",
"section.empty.kc_targets": "No key color targets yet. Click + to add one.",
"section.empty.pattern_templates": "No pattern templates yet. Click + to add one.",
"section.empty.picture_sources": "No sources yet. Click + to add one.",
"section.empty.capture_templates": "No capture templates yet. Click + to add one.",
"section.empty.pp_templates": "No post-processing templates yet. Click + to add one.",
"section.empty.audio_sources": "No audio sources yet. Click + to add one.",
"section.empty.audio_templates": "No audio templates yet. Click + to add one.",
"section.empty.color_strips": "No color strips yet. Click + to add one.",
"section.empty.value_sources": "No value sources yet. Click + to add one.",
"section.empty.sync_clocks": "No sync clocks yet. Click + to add one.",
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.automations": "No automations yet. Click + to add one.",
"section.empty.scenes": "No scene presets yet. Click + to add one."
} }

View File

@@ -29,6 +29,7 @@
"auth.toggle_password": "Показать/скрыть пароль", "auth.toggle_password": "Показать/скрыть пароль",
"auth.prompt_enter": "Enter your API key:", "auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "Войти",
"displays.title": "Доступные Дисплеи", "displays.title": "Доступные Дисплеи",
"displays.layout": "Дисплеи", "displays.layout": "Дисплеи",
"displays.information": "Информация о Дисплеях", "displays.information": "Информация о Дисплеях",
@@ -291,6 +292,12 @@
"device.health.offline": "Недоступен", "device.health.offline": "Недоступен",
"device.health.streaming_unreachable": "Недоступен во время стриминга", "device.health.streaming_unreachable": "Недоступен во время стриминга",
"device.health.checking": "Проверка...", "device.health.checking": "Проверка...",
"device.last_seen.label": "Последний раз",
"device.last_seen.just_now": "только что",
"device.last_seen.seconds": "%d с назад",
"device.last_seen.minutes": "%d мин назад",
"device.last_seen.hours": "%d ч назад",
"device.last_seen.days": "%d д назад",
"device.tutorial.start": "Начать обучение", "device.tutorial.start": "Начать обучение",
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически", "device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости", "device.tip.brightness": "Перетащите для регулировки яркости",
@@ -402,9 +409,11 @@
"error.network": "Сетевая ошибка", "error.network": "Сетевая ошибка",
"error.unknown": "Произошла ошибка", "error.unknown": "Произошла ошибка",
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?", "modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
"confirm.title": "Подтверждение Действия", "confirm.title": "Подтверждение",
"confirm.yes": "Да", "confirm.yes": "Да",
"confirm.no": "Нет", "confirm.no": "Нет",
"confirm.stop_all": "Остановить все запущенные цели?",
"confirm.turn_off_device": "Выключить это устройство?",
"common.loading": "Загрузка...", "common.loading": "Загрузка...",
"common.delete": "Удалить", "common.delete": "Удалить",
"common.edit": "Редактировать", "common.edit": "Редактировать",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "Источники цветовых полос", "targets.section.color_strips": "Источники цветовых полос",
"targets.section.targets": "Цели", "targets.section.targets": "Цели",
"targets.section.specific_settings": "Специальные настройки", "targets.section.specific_settings": "Специальные настройки",
"targets.section.advanced": "Расширенные",
"targets.add": "Добавить Цель", "targets.add": "Добавить Цель",
"targets.edit": "Редактировать Цель", "targets.edit": "Редактировать Цель",
"targets.loading": "Загрузка целей...", "targets.loading": "Загрузка целей...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "Холодный", "color_strip.gradient.preset.cool": "Холодный",
"color_strip.gradient.preset.neon": "Неон", "color_strip.gradient.preset.neon": "Неон",
"color_strip.gradient.preset.pastel": "Пастельный", "color_strip.gradient.preset.pastel": "Пастельный",
"color_strip.gradient.preset.save_button": "Сохранить как пресет…",
"color_strip.gradient.preset.save_prompt": "Введите название пресета:",
"color_strip.gradient.preset.saved": "Пресет сохранён",
"color_strip.gradient.preset.deleted": "Пресет удалён",
"color_strip.gradient.preset.apply": "Применить",
"color_strip.animation": "Анимация", "color_strip.animation": "Анимация",
"color_strip.animation.type": "Эффект:", "color_strip.animation.type": "Эффект:",
"color_strip.animation.type.hint": "Эффект анимации.", "color_strip.animation.type.hint": "Эффект анимации.",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "Уведомление отправлено", "color_strip.notification.test.ok": "Уведомление отправлено",
"color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника", "color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника",
"color_strip.notification.test.error": "Не удалось отправить уведомление", "color_strip.notification.test.error": "Не удалось отправить уведомление",
"color_strip.notification.history.title": "История уведомлений",
"color_strip.notification.history.hint": "Последние ОС-уведомления, захваченные слушателем (новейшие сверху). До 50 записей.",
"color_strip.notification.history.empty": "Уведомления ещё не захвачены",
"color_strip.notification.history.unavailable": "Слушатель уведомлений ОС недоступен на этой платформе",
"color_strip.notification.history.error": "Не удалось загрузить историю уведомлений",
"color_strip.notification.history.refresh": "Обновить",
"color_strip.notification.history.unknown_app": "Неизвестное приложение",
"color_strip.notification.history.fired": "Потоков запущено",
"color_strip.notification.history.filtered": "Потоков отфильтровано",
"color_strip.test.title": "Предпросмотр", "color_strip.test.title": "Предпросмотр",
"color_strip.test.connecting": "Подключение...", "color_strip.test.connecting": "Подключение...",
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "Моно", "audio_source.type.mono": "Моно",
"audio_source.device": "Аудиоустройство:", "audio_source.device": "Аудиоустройство:",
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", "audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
"audio_source.refresh_devices": "Обновить устройства",
"audio_source.parent": "Родительский источник:", "audio_source.parent": "Родительский источник:",
"audio_source.parent.hint": "Многоканальный источник для извлечения канала", "audio_source.parent.hint": "Многоканальный источник для извлечения канала",
"audio_source.channel": "Канал:", "audio_source.channel": "Канал:",
@@ -1375,6 +1400,13 @@
"search.group.value": "Источники значений", "search.group.value": "Источники значений",
"search.group.scenes": "Пресеты сцен", "search.group.scenes": "Пресеты сцен",
"search.group.cspt": "Шаблоны обработки полос", "search.group.cspt": "Шаблоны обработки полос",
"search.group.sync_clocks": "Синхронные часы",
"search.group.actions": "Действия",
"search.action.start": "Запустить",
"search.action.stop": "Остановить",
"search.action.activate": "Активировать",
"search.action.enable": "Включить",
"search.action.disable": "Отключить",
"settings.backup.label": "Резервное копирование", "settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию", "settings.backup.button": "Скачать резервную копию",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "Ошибка восстановления", "settings.restore.error": "Ошибка восстановления",
"settings.restore.restarting": "Сервер перезапускается...", "settings.restore.restarting": "Сервер перезапускается...",
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
"settings.restart_server": "Перезапустить сервер",
"settings.restart_confirm": "Перезапустить сервер? Активные цели будут остановлены.",
"settings.restarting": "Перезапуск сервера...",
"settings.button.close": "Закрыть", "settings.button.close": "Закрыть",
"settings.log_level.label": "Уровень логирования",
"settings.log_level.hint": "Изменить подробность логов сервера в реальном времени. DEBUG — максимум деталей, CRITICAL — только критические ошибки.",
"settings.log_level.save": "Применить",
"settings.log_level.saved": "Уровень логирования изменён",
"settings.log_level.save_error": "Не удалось изменить уровень логирования",
"settings.auto_backup.label": "Авто-бэкап", "settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап", "settings.auto_backup.enable": "Включить авто-бэкап",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "Удалить", "settings.saved_backups.delete": "Удалить",
"settings.saved_backups.delete_confirm": "Удалить эту резервную копию?", "settings.saved_backups.delete_confirm": "Удалить эту резервную копию?",
"settings.saved_backups.delete_error": "Не удалось удалить копию", "settings.saved_backups.delete_error": "Не удалось удалить копию",
"settings.saved_backups.type.auto": "авто",
"settings.saved_backups.type.manual": "ручной",
"settings.mqtt.label": "MQTT",
"settings.mqtt.hint": "Настройте подключение к MQTT-брокеру для условий и триггеров автоматизации.",
"settings.mqtt.enabled": "Включить MQTT",
"settings.mqtt.host_label": "Хост брокера",
"settings.mqtt.port_label": "Порт",
"settings.mqtt.username_label": "Имя пользователя",
"settings.mqtt.password_label": "Пароль",
"settings.mqtt.password_set_hint": "Пароль задан — оставьте пустым, чтобы сохранить",
"settings.mqtt.client_id_label": "Идентификатор клиента",
"settings.mqtt.base_topic_label": "Базовый топик",
"settings.mqtt.save": "Сохранить настройки MQTT",
"settings.mqtt.saved": "Настройки MQTT сохранены",
"settings.mqtt.save_error": "Не удалось сохранить настройки MQTT",
"settings.mqtt.error_host_required": "Требуется указать хост брокера",
"settings.logs.label": "Журнал сервера",
"settings.logs.hint": "Просмотр журнала сервера в реальном времени. Используйте фильтр для отображения нужных уровней.",
"settings.logs.connect": "Подключить",
"settings.logs.disconnect": "Отключить",
"settings.logs.clear": "Очистить",
"settings.logs.error": "Ошибка подключения к журналу",
"settings.logs.filter.all": "Все уровни",
"settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "Только ошибки",
"device.error.power_off_failed": "Не удалось выключить устройство", "device.error.power_off_failed": "Не удалось выключить устройство",
"device.removed": "Устройство удалено", "device.removed": "Устройство удалено",
"device.error.remove_failed": "Не удалось удалить устройство", "device.error.remove_failed": "Не удалось удалить устройство",
@@ -1415,6 +1481,7 @@
"device.error.required": "Пожалуйста, заполните все поля", "device.error.required": "Пожалуйста, заполните все поля",
"device.error.update": "Не удалось обновить устройство", "device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки", "device.error.save": "Не удалось сохранить настройки",
"device.error.clone_failed": "Не удалось клонировать устройство",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля", "device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено", "device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство", "device_discovery.error.add_failed": "Не удалось добавить устройство",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "Часы возобновлены", "sync_clock.resumed": "Часы возобновлены",
"sync_clock.reset_done": "Часы сброшены на ноль", "sync_clock.reset_done": "Часы сброшены на ноль",
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.", "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
"sync_clock.elapsed": "Прошло времени",
"color_strip.clock": "Часы синхронизации:", "color_strip.clock": "Часы синхронизации:",
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.", "color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
"graph.title": "Граф", "graph.title": "Граф",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "Отсоединить связь", "graph.help.right_click_desc": "Отсоединить связь",
"automation.enabled": "Автоматизация включена", "automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена", "automation.disabled": "Автоматизация выключена",
"scene_preset.activated": "Пресет активирован" "scene_preset.activated": "Пресет активирован",
"scene_preset.used_by": "Используется в %d автоматизации(ях)",
"settings.api_keys.label": "API-ключи",
"settings.api_keys.hint": "API-ключи определяются в конфигурационном файле сервера (config.yaml). Отредактируйте файл и перезапустите сервер для применения изменений.",
"settings.api_keys.empty": "API-ключи не настроены",
"settings.api_keys.load_error": "Не удалось загрузить API-ключи",
"settings.partial.label": "Частичный экспорт / импорт",
"settings.partial.hint": "Экспортировать или импортировать один тип объектов. Импорт заменяет или объединяет данные и перезапускает сервер.",
"settings.partial.store.devices": "Устройства",
"settings.partial.store.output_targets": "LED-цели",
"settings.partial.store.color_strip_sources": "Цветные полосы",
"settings.partial.store.picture_sources": "Источники изображений",
"settings.partial.store.audio_sources": "Аудио-источники",
"settings.partial.store.audio_templates": "Аудио-шаблоны",
"settings.partial.store.capture_templates": "Шаблоны захвата",
"settings.partial.store.postprocessing_templates": "Шаблоны постобработки",
"settings.partial.store.color_strip_processing_templates": "Шаблоны обработки полос",
"settings.partial.store.pattern_templates": "Шаблоны паттернов",
"settings.partial.store.value_sources": "Источники значений",
"settings.partial.store.sync_clocks": "Синхронные часы",
"settings.partial.store.automations": "Автоматизации",
"settings.partial.store.scene_presets": "Пресеты сцен",
"settings.partial.export_button": "Экспорт",
"settings.partial.import_button": "Импорт из файла",
"settings.partial.merge_label": "Объединить (добавить/перезаписать, сохранить существующие)",
"settings.partial.export_success": "Экспорт выполнен",
"settings.partial.export_error": "Ошибка экспорта",
"settings.partial.import_success": "Импорт выполнен",
"settings.partial.import_error": "Ошибка импорта",
"settings.partial.import_confirm_replace": "Это ЗАМЕНИТ все данные {store} и перезапустит сервер. Продолжить?",
"settings.partial.import_confirm_merge": "Это ОБЪЕДИНИТ данные {store} и перезапустит сервер. Продолжить?",
"section.empty.devices": "Устройств пока нет. Нажмите + для добавления.",
"section.empty.targets": "LED-целей пока нет. Нажмите + для добавления.",
"section.empty.kc_targets": "Целей ключевых цветов пока нет. Нажмите + для добавления.",
"section.empty.pattern_templates": "Шаблонов паттернов пока нет. Нажмите + для добавления.",
"section.empty.picture_sources": "Источников пока нет. Нажмите + для добавления.",
"section.empty.capture_templates": "Шаблонов захвата пока нет. Нажмите + для добавления.",
"section.empty.pp_templates": "Шаблонов постобработки пока нет. Нажмите + для добавления.",
"section.empty.audio_sources": "Аудио-источников пока нет. Нажмите + для добавления.",
"section.empty.audio_templates": "Аудио-шаблонов пока нет. Нажмите + для добавления.",
"section.empty.color_strips": "Цветных полос пока нет. Нажмите + для добавления.",
"section.empty.value_sources": "Источников значений пока нет. Нажмите + для добавления.",
"section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.",
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления."
} }

View File

@@ -29,6 +29,7 @@
"auth.toggle_password": "切换密码可见性", "auth.toggle_password": "切换密码可见性",
"auth.prompt_enter": "Enter your API key:", "auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "登录",
"displays.title": "可用显示器", "displays.title": "可用显示器",
"displays.layout": "显示器", "displays.layout": "显示器",
"displays.information": "显示器信息", "displays.information": "显示器信息",
@@ -291,6 +292,12 @@
"device.health.offline": "离线", "device.health.offline": "离线",
"device.health.streaming_unreachable": "流传输期间不可达", "device.health.streaming_unreachable": "流传输期间不可达",
"device.health.checking": "检测中...", "device.health.checking": "检测中...",
"device.last_seen.label": "最近检测",
"device.last_seen.just_now": "刚刚",
"device.last_seen.seconds": "%d秒前",
"device.last_seen.minutes": "%d分钟前",
"device.last_seen.hours": "%d小时前",
"device.last_seen.days": "%d天前",
"device.tutorial.start": "开始教程", "device.tutorial.start": "开始教程",
"device.tip.metadata": "设备信息LED 数量、类型、颜色通道)从设备自动检测", "device.tip.metadata": "设备信息LED 数量、类型、颜色通道)从设备自动检测",
"device.tip.brightness": "滑动调节设备亮度", "device.tip.brightness": "滑动调节设备亮度",
@@ -405,6 +412,8 @@
"confirm.title": "确认操作", "confirm.title": "确认操作",
"confirm.yes": "是", "confirm.yes": "是",
"confirm.no": "否", "confirm.no": "否",
"confirm.stop_all": "停止所有运行中的目标?",
"confirm.turn_off_device": "关闭此设备?",
"common.loading": "加载中...", "common.loading": "加载中...",
"common.delete": "删除", "common.delete": "删除",
"common.edit": "编辑", "common.edit": "编辑",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "色带源", "targets.section.color_strips": "色带源",
"targets.section.targets": "目标", "targets.section.targets": "目标",
"targets.section.specific_settings": "特定设置", "targets.section.specific_settings": "特定设置",
"targets.section.advanced": "高级",
"targets.add": "添加目标", "targets.add": "添加目标",
"targets.edit": "编辑目标", "targets.edit": "编辑目标",
"targets.loading": "正在加载目标...", "targets.loading": "正在加载目标...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "冷色", "color_strip.gradient.preset.cool": "冷色",
"color_strip.gradient.preset.neon": "霓虹", "color_strip.gradient.preset.neon": "霓虹",
"color_strip.gradient.preset.pastel": "柔和", "color_strip.gradient.preset.pastel": "柔和",
"color_strip.gradient.preset.save_button": "保存为预设…",
"color_strip.gradient.preset.save_prompt": "输入预设名称:",
"color_strip.gradient.preset.saved": "预设已保存",
"color_strip.gradient.preset.deleted": "预设已删除",
"color_strip.gradient.preset.apply": "应用",
"color_strip.animation": "动画", "color_strip.animation": "动画",
"color_strip.animation.type": "效果:", "color_strip.animation.type": "效果:",
"color_strip.animation.type.hint": "要应用的动画效果。", "color_strip.animation.type.hint": "要应用的动画效果。",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "通知已发送", "color_strip.notification.test.ok": "通知已发送",
"color_strip.notification.test.no_streams": "此源没有运行中的流", "color_strip.notification.test.no_streams": "此源没有运行中的流",
"color_strip.notification.test.error": "发送通知失败", "color_strip.notification.test.error": "发送通知失败",
"color_strip.notification.history.title": "通知历史",
"color_strip.notification.history.hint": "监听器捕获的最近OS通知最新在前最多50条。",
"color_strip.notification.history.empty": "尚未捕获任何通知",
"color_strip.notification.history.unavailable": "此平台不支持OS通知监听器",
"color_strip.notification.history.error": "加载通知历史失败",
"color_strip.notification.history.refresh": "刷新",
"color_strip.notification.history.unknown_app": "未知应用",
"color_strip.notification.history.fired": "触发的流数量",
"color_strip.notification.history.filtered": "过滤的流数量",
"color_strip.test.title": "预览测试", "color_strip.test.title": "预览测试",
"color_strip.test.connecting": "连接中...", "color_strip.test.connecting": "连接中...",
"color_strip.test.error": "无法连接到预览流", "color_strip.test.error": "无法连接到预览流",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "单声道", "audio_source.type.mono": "单声道",
"audio_source.device": "音频设备:", "audio_source.device": "音频设备:",
"audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。", "audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。",
"audio_source.refresh_devices": "刷新设备",
"audio_source.parent": "父源:", "audio_source.parent": "父源:",
"audio_source.parent.hint": "要从中提取通道的多声道源", "audio_source.parent.hint": "要从中提取通道的多声道源",
"audio_source.channel": "通道:", "audio_source.channel": "通道:",
@@ -1375,6 +1400,13 @@
"search.group.value": "值源", "search.group.value": "值源",
"search.group.scenes": "场景预设", "search.group.scenes": "场景预设",
"search.group.cspt": "色带处理模板", "search.group.cspt": "色带处理模板",
"search.group.sync_clocks": "同步时钟",
"search.group.actions": "操作",
"search.action.start": "启动",
"search.action.stop": "停止",
"search.action.activate": "激活",
"search.action.enable": "启用",
"search.action.disable": "禁用",
"settings.backup.label": "备份配置", "settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份", "settings.backup.button": "下载备份",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "恢复失败", "settings.restore.error": "恢复失败",
"settings.restore.restarting": "服务器正在重启...", "settings.restore.restarting": "服务器正在重启...",
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
"settings.restart_server": "重启服务器",
"settings.restart_confirm": "重启服务器?活跃的目标将被停止。",
"settings.restarting": "正在重启服务器...",
"settings.button.close": "关闭", "settings.button.close": "关闭",
"settings.log_level.label": "日志级别",
"settings.log_level.hint": "实时更改服务器日志详细程度。DEBUG 显示最多细节CRITICAL 仅显示致命错误。",
"settings.log_level.save": "应用",
"settings.log_level.saved": "日志级别已更改",
"settings.log_level.save_error": "更改日志级别失败",
"settings.auto_backup.label": "自动备份", "settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份", "settings.auto_backup.enable": "启用自动备份",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "删除", "settings.saved_backups.delete": "删除",
"settings.saved_backups.delete_confirm": "删除此备份文件?", "settings.saved_backups.delete_confirm": "删除此备份文件?",
"settings.saved_backups.delete_error": "删除备份失败", "settings.saved_backups.delete_error": "删除备份失败",
"settings.saved_backups.type.auto": "自动",
"settings.saved_backups.type.manual": "手动",
"settings.mqtt.label": "MQTT",
"settings.mqtt.hint": "配置 MQTT 代理连接,用于自动化条件和触发器。",
"settings.mqtt.enabled": "启用 MQTT",
"settings.mqtt.host_label": "代理主机",
"settings.mqtt.port_label": "端口",
"settings.mqtt.username_label": "用户名",
"settings.mqtt.password_label": "密码",
"settings.mqtt.password_set_hint": "已设置密码 — 留空以保留",
"settings.mqtt.client_id_label": "客户端 ID",
"settings.mqtt.base_topic_label": "基础主题",
"settings.mqtt.save": "保存 MQTT 设置",
"settings.mqtt.saved": "MQTT 设置已保存",
"settings.mqtt.save_error": "保存 MQTT 设置失败",
"settings.mqtt.error_host_required": "代理主机不能为空",
"settings.logs.label": "服务器日志",
"settings.logs.hint": "实时查看服务器日志。使用过滤器显示所需的日志级别。",
"settings.logs.connect": "连接",
"settings.logs.disconnect": "断开",
"settings.logs.clear": "清除",
"settings.logs.error": "日志查看器连接失败",
"settings.logs.filter.all": "所有级别",
"settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "仅错误",
"device.error.power_off_failed": "关闭设备失败", "device.error.power_off_failed": "关闭设备失败",
"device.removed": "设备已移除", "device.removed": "设备已移除",
"device.error.remove_failed": "移除设备失败", "device.error.remove_failed": "移除设备失败",
@@ -1415,6 +1481,7 @@
"device.error.required": "请填写所有字段", "device.error.required": "请填写所有字段",
"device.error.update": "更新设备失败", "device.error.update": "更新设备失败",
"device.error.save": "保存设置失败", "device.error.save": "保存设置失败",
"device.error.clone_failed": "克隆设备失败",
"device_discovery.error.fill_all_fields": "请填写所有字段", "device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功", "device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败", "device_discovery.error.add_failed": "添加设备失败",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "时钟已恢复", "sync_clock.resumed": "时钟已恢复",
"sync_clock.reset_done": "时钟已重置为零", "sync_clock.reset_done": "时钟已重置为零",
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。", "sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
"sync_clock.elapsed": "已用时间",
"color_strip.clock": "同步时钟:", "color_strip.clock": "同步时钟:",
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。", "color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
"graph.title": "图表", "graph.title": "图表",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "断开连接", "graph.help.right_click_desc": "断开连接",
"automation.enabled": "自动化已启用", "automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用", "automation.disabled": "自动化已禁用",
"scene_preset.activated": "预设已激活" "scene_preset.activated": "预设已激活",
"scene_preset.used_by": "被 %d 个自动化使用",
"settings.api_keys.label": "API 密钥",
"settings.api_keys.hint": "API 密钥在服务器配置文件 (config.yaml) 中定义。编辑文件并重启服务器以应用更改。",
"settings.api_keys.empty": "未配置 API 密钥",
"settings.api_keys.load_error": "加载 API 密钥失败",
"settings.partial.label": "部分导出 / 导入",
"settings.partial.hint": "导出或导入单个实体类型。导入会替换或合并现有数据并重启服务器。",
"settings.partial.store.devices": "设备",
"settings.partial.store.output_targets": "LED 目标",
"settings.partial.store.color_strip_sources": "色带",
"settings.partial.store.picture_sources": "图像源",
"settings.partial.store.audio_sources": "音频源",
"settings.partial.store.audio_templates": "音频模板",
"settings.partial.store.capture_templates": "捕获模板",
"settings.partial.store.postprocessing_templates": "后处理模板",
"settings.partial.store.color_strip_processing_templates": "CSS 处理模板",
"settings.partial.store.pattern_templates": "图案模板",
"settings.partial.store.value_sources": "值源",
"settings.partial.store.sync_clocks": "同步时钟",
"settings.partial.store.automations": "自动化",
"settings.partial.store.scene_presets": "场景预设",
"settings.partial.export_button": "导出",
"settings.partial.import_button": "从文件导入",
"settings.partial.merge_label": "合并(添加/覆盖,保留现有)",
"settings.partial.export_success": "导出成功",
"settings.partial.export_error": "导出失败",
"settings.partial.import_success": "导入成功",
"settings.partial.import_error": "导入失败",
"settings.partial.import_confirm_replace": "这将替换所有 {store} 数据并重启服务器。继续吗?",
"settings.partial.import_confirm_merge": "这将合并 {store} 数据并重启服务器。继续吗?",
"section.empty.devices": "暂无设备。点击 + 添加。",
"section.empty.targets": "暂无 LED 目标。点击 + 添加。",
"section.empty.kc_targets": "暂无键色目标。点击 + 添加。",
"section.empty.pattern_templates": "暂无图案模板。点击 + 添加。",
"section.empty.picture_sources": "暂无源。点击 + 添加。",
"section.empty.capture_templates": "暂无捕获模板。点击 + 添加。",
"section.empty.pp_templates": "暂无后处理模板。点击 + 添加。",
"section.empty.audio_sources": "暂无音频源。点击 + 添加。",
"section.empty.audio_templates": "暂无音频模板。点击 + 添加。",
"section.empty.color_strips": "暂无色带。点击 + 添加。",
"section.empty.value_sources": "暂无值源。点击 + 添加。",
"section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。",
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。",
"section.empty.scenes": "暂无场景预设。点击 + 添加。"
} }

View File

@@ -167,6 +167,7 @@
{% include 'modals/target-editor.html' %} {% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %} {% include 'modals/css-editor.html' %}
{% include 'modals/test-css-source.html' %} {% include 'modals/test-css-source.html' %}
{% include 'modals/notification-history.html' %}
{% include 'modals/kc-editor.html' %} {% include 'modals/kc-editor.html' %}
{% include 'modals/pattern-template.html' %} {% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %} {% include 'modals/api-key.html' %}

View File

@@ -33,7 +33,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button> <button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button type="submit" class="btn btn-icon btn-primary" title="Login" data-i18n-aria-label="aria.save">&#x2713;</button> <button type="submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -44,9 +44,12 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small> <small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small>
<select id="audio-source-device"> <div class="select-with-action">
<!-- populated dynamically --> <select id="audio-source-device">
</select> <!-- populated dynamically -->
</select>
<button type="button" class="btn btn-secondary btn-sm" id="audio-source-refresh-devices" onclick="refreshAudioDevices()" data-i18n-title="audio_source.refresh_devices" title="Refresh devices">&#x21BB;</button>
</div>
</div> </div>
</div> </div>

View File

@@ -81,16 +81,16 @@
</div> </div>
<!-- Edge test toggle zones (outside container border, in tick area) --> <!-- Edge test toggle zones (outside container border, in tick area) -->
<div class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></div> <button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
<div class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></div> <button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
<div class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></div> <button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
<div class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></div> <button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
<!-- Corner start position buttons --> <!-- Corner start position buttons -->
<div class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></div> <button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></button>
<div class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></div> <button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></button>
<div class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></div> <button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></button>
<div class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></div> <button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></button>
<!-- Canvas overlay for ticks, arrows, start label --> <!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas> <canvas id="calibration-preview-canvas"></canvas>
@@ -141,7 +141,7 @@
</div> </div>
<!-- Tutorial Overlay --> <!-- Tutorial Overlay -->
<div id="tutorial-overlay" class="tutorial-overlay"> <div id="calibration-tutorial-overlay" class="tutorial-overlay">
<div class="tutorial-backdrop"></div> <div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div> <div class="tutorial-ring"></div>
<div class="tutorial-tooltip"> <div class="tutorial-tooltip">

View File

@@ -2,15 +2,15 @@
<div id="confirm-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title"> <div id="confirm-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="modal-content" style="max-width: 450px;"> <div class="modal-content" style="max-width: 450px;">
<div class="modal-header"> <div class="modal-header">
<h2 id="confirm-modal-title">Confirm Action</h2> <h2 id="confirm-modal-title" data-i18n="confirm.title">Confirm Action</h2>
<button class="modal-close-btn" onclick="closeConfirmModal(false)" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeConfirmModal(false)" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="confirm-message" class="modal-description"></p> <p id="confirm-message" class="modal-description"></p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button> <button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)" data-i18n="confirm.no">No</button>
<button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button> <button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)" data-i18n="confirm.yes">Yes</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -109,7 +109,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preset.hint">Load a predefined gradient palette. Selecting a preset replaces the current stops.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preset.hint">Load a predefined gradient palette. Selecting a preset replaces the current stops.</small>
<select id="css-editor-gradient-preset" onchange="applyGradientPreset(this.value)"> <select id="css-editor-gradient-preset" onchange="onGradientPresetChange(this.value)">
<option value="" data-i18n="color_strip.gradient.preset.custom">— Custom —</option> <option value="" data-i18n="color_strip.gradient.preset.custom">— Custom —</option>
<option value="rainbow" data-i18n="color_strip.gradient.preset.rainbow">Rainbow</option> <option value="rainbow" data-i18n="color_strip.gradient.preset.rainbow">Rainbow</option>
<option value="sunset" data-i18n="color_strip.gradient.preset.sunset">Sunset</option> <option value="sunset" data-i18n="color_strip.gradient.preset.sunset">Sunset</option>
@@ -124,6 +124,12 @@
<option value="neon" data-i18n="color_strip.gradient.preset.neon">Neon</option> <option value="neon" data-i18n="color_strip.gradient.preset.neon">Neon</option>
<option value="pastel" data-i18n="color_strip.gradient.preset.pastel">Pastel</option> <option value="pastel" data-i18n="color_strip.gradient.preset.pastel">Pastel</option>
</select> </select>
<div style="margin-top:6px;">
<button type="button" class="btn btn-secondary btn-sm"
onclick="promptAndSaveGradientPreset()"
data-i18n="color_strip.gradient.preset.save_button">Save as preset…</button>
</div>
<div id="css-editor-custom-presets-list" class="custom-presets-list"></div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -0,0 +1,18 @@
<!-- OS Notification History Modal -->
<div id="notification-history-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="notification-history-modal-title" style="display:none">
<div class="modal-content" style="max-width:520px;width:100%">
<div class="modal-header">
<h2 id="notification-history-modal-title" data-i18n="color_strip.notification.history.title">Notification History</h2>
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="refreshNotificationHistory()" data-i18n="color_strip.notification.history.refresh">Refresh</button>
<button class="btn btn-secondary" onclick="closeNotificationHistory()" data-i18n="settings.button.cancel">Close</button>
</div>
</div>
</div>

View File

@@ -6,6 +6,16 @@
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- API Keys section (read-only) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.api_keys.label">API Keys</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div>
<!-- Backup section --> <!-- Backup section -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -27,6 +37,43 @@
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button> <button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
</div> </div>
<!-- Partial Export/Import section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.partial.label">Partial Export / Import</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
<select id="settings-partial-store" style="flex:1">
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
</select>
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<input type="checkbox" id="settings-partial-merge">
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
</div>
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
</div>
<!-- Auto-Backup section --> <!-- Auto-Backup section -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -73,10 +120,109 @@
<div id="saved-backups-list"></div> <div id="saved-backups-list"></div>
</div> </div>
<!-- MQTT section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="mqtt-enabled">
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
</div>
<div style="width:90px">
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
<div style="flex:1">
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
</div>
</div>
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div>
<!-- Server Logs section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
<option value="all" data-i18n="settings.logs.filter.all">All</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<div style="display:flex;gap:0.5rem;">
<select id="settings-log-level" style="flex:1">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
<button class="btn btn-primary" onclick="setLogLevel()" data-i18n="settings.log_level.save">Apply</button>
</div>
</div>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div>
<div id="settings-error" class="error-message" style="display:none;"></div> <div id="settings-error" class="error-message" style="display:none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeSettingsModal()" title="Close" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeSettingsModal()" title="Close" data-i18n-title="settings.button.close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -61,33 +61,33 @@
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small> <small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-brightness-threshold-group"> <details class="form-collapse" id="target-editor-advanced-settings">
<div class="label-row"> <summary data-i18n="targets.section.advanced">Advanced</summary>
<label for="target-editor-brightness-threshold">
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span id="target-editor-brightness-threshold-value">0</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-adaptive-fps-group">
<div class="label-row">
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
<label class="settings-toggle">
<input type="checkbox" id="target-editor-adaptive-fps">
<span class="settings-toggle-slider"></span>
</label>
</div>
<details class="form-collapse" id="target-editor-device-settings">
<summary data-i18n="targets.section.specific_settings">Specific Settings</summary>
<div class="form-collapse-body"> <div class="form-collapse-body">
<div class="form-group" id="target-editor-brightness-threshold-group">
<div class="label-row">
<label for="target-editor-brightness-threshold">
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span id="target-editor-brightness-threshold-value">0</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-adaptive-fps-group">
<div class="label-row">
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
<label class="settings-toggle">
<input type="checkbox" id="target-editor-adaptive-fps">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="target-editor-protocol-group"> <div class="form-group" id="target-editor-protocol-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label> <label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label>

View File

@@ -66,6 +66,8 @@
<option value="square" data-i18n="value_source.waveform.square">Square</option> <option value="square" data-i18n="value_source.waveform.square">Square</option>
<option value="sawtooth" data-i18n="value_source.waveform.sawtooth">Sawtooth</option> <option value="sawtooth" data-i18n="value_source.waveform.sawtooth">Sawtooth</option>
</select> </select>
<canvas id="value-source-waveform-preview" width="200" height="60"
style="display:block;margin-top:8px;border-radius:6px;background:var(--surface-2,#1e1e2e);width:100%;max-width:200px;height:60px;"></canvas>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -4,5 +4,16 @@ from .file_ops import atomic_write_json
from .logger import setup_logging, get_logger from .logger import setup_logging, get_logger
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
from .timer import high_resolution_timer from .timer import high_resolution_timer
from .log_broadcaster import broadcaster as log_broadcaster, install_broadcast_handler
__all__ = ["atomic_write_json", "setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"] __all__ = [
"atomic_write_json",
"setup_logging",
"get_logger",
"get_monitor_names",
"get_monitor_name",
"get_monitor_refresh_rates",
"high_resolution_timer",
"log_broadcaster",
"install_broadcast_handler",
]

View File

@@ -0,0 +1,118 @@
"""Log broadcaster: in-memory ring buffer + WebSocket fan-out for live log tailing."""
import asyncio
import logging
from collections import deque
from typing import Set
# Maximum number of log records kept in memory
_BACKLOG_SIZE = 500
# A simple text formatter used by the broadcast handler
_formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s%(message)s")
class LogBroadcaster:
"""Singleton that buffers recent log lines and fans them out to WS clients."""
def __init__(self, maxlen: int = _BACKLOG_SIZE) -> None:
self._backlog: deque[str] = deque(maxlen=maxlen)
self._clients: Set[asyncio.Queue] = set()
# The async event loop where send_to_clients() is scheduled.
# Set lazily on the first WS connection (inside the async context).
self._loop: asyncio.AbstractEventLoop | None = None
# ------------------------------------------------------------------
# Called from the logging.Handler (any thread)
# ------------------------------------------------------------------
def emit(self, line: str) -> None:
"""Append *line* to the backlog and notify all connected WS clients.
Safe to call from any thread — it schedules the async notification on
the server's event loop without blocking.
"""
self._backlog.append(line)
if self._clients and self._loop is not None:
try:
self._loop.call_soon_threadsafe(self._enqueue_line, line)
except RuntimeError:
# Loop closed / not running — silently drop
pass
def _enqueue_line(self, line: str) -> None:
"""Push *line* onto every connected client's queue (called from the event loop)."""
dead: Set[asyncio.Queue] = set()
for q in self._clients:
try:
q.put_nowait(line)
except asyncio.QueueFull:
dead.add(q)
self._clients -= dead
# ------------------------------------------------------------------
# Called from the async WS handler
# ------------------------------------------------------------------
def subscribe(self) -> "asyncio.Queue[str]":
"""Register a new WS client and return its private line queue."""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
q: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
self._clients.add(q)
return q
def unsubscribe(self, q: "asyncio.Queue[str]") -> None:
"""Remove a WS client queue."""
self._clients.discard(q)
def get_backlog(self) -> list[str]:
"""Return a snapshot of the current backlog (oldest → newest)."""
return list(self._backlog)
def ensure_loop(self) -> None:
"""Capture the running event loop if not already stored.
Call this once from an async context (e.g. the WS endpoint) so that
thread-safe scheduling works correctly even before the first client
connects.
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
# Module-level singleton
broadcaster = LogBroadcaster()
class BroadcastLogHandler(logging.Handler):
"""A logging.Handler that pushes formatted records into the LogBroadcaster."""
def __init__(self) -> None:
super().__init__()
self.setFormatter(_formatter)
def emit(self, record: logging.LogRecord) -> None: # type: ignore[override]
try:
line = self.format(record)
broadcaster.emit(line)
except Exception:
self.handleError(record)
def install_broadcast_handler() -> None:
"""Attach BroadcastLogHandler to the root logger (idempotent)."""
root = logging.getLogger()
# Avoid double-installation on hot reload / re-import
for h in root.handlers:
if isinstance(h, BroadcastLogHandler):
return
handler = BroadcastLogHandler()
handler.setLevel(logging.DEBUG)
root.addHandler(handler)