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

View File

@@ -12,6 +12,11 @@
--warning-color: #ff9800;
--info-color: #2196F3;
--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 ── */
@@ -34,6 +39,7 @@
--bg-secondary: #242424;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--text-primary: #e0e0e0;
--text-secondary: #999;
--text-muted: #777;
--border-color: #404040;
@@ -41,6 +47,8 @@
--primary-text-color: #66bb6a;
--success-color: #28a745;
--shadow-color: rgba(0, 0, 0, 0.3);
--hover-bg: rgba(255, 255, 255, 0.05);
--input-bg: #1a1a2e;
color-scheme: dark;
}
@@ -50,6 +58,7 @@
--bg-secondary: #eee;
--card-bg: #ffffff;
--text-color: #333333;
--text-primary: #333333;
--text-secondary: #666;
--text-muted: #999;
--border-color: #e0e0e0;
@@ -57,6 +66,8 @@
--primary-text-color: #3d8b40;
--success-color: #2e7d32;
--shadow-color: rgba(0, 0, 0, 0.12);
--hover-bg: rgba(0, 0, 0, 0.05);
--input-bg: #f0f0f0;
color-scheme: light;
}
@@ -69,6 +80,7 @@ body {
html {
background: var(--bg-color);
overflow-y: scroll;
scroll-behavior: smooth;
}
body {
@@ -184,4 +196,16 @@ header {
animation-iteration-count: 1 !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 {
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);
}
@@ -53,7 +53,7 @@ section {
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 12px 20px 20px;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
@@ -243,7 +243,7 @@ section {
.card-drag-placeholder {
border: 2px dashed var(--primary-color);
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;
transition: none;
}
@@ -378,7 +378,7 @@ body.cs-drag-active .card-drag-handle {
.card-power-btn:hover {
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 {
@@ -401,7 +401,7 @@ body.cs-drag-active .card-drag-handle {
.card-remove-btn:hover {
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 {
@@ -569,7 +569,7 @@ body.cs-drag-active .card-drag-handle {
}
.zone-checkbox-list .zone-loading,
.zone-checkbox-list .zone-error {
font-size: 12px;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 4px 0;
}
@@ -581,7 +581,7 @@ body.cs-drag-active .card-drag-handle {
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: 0.8125rem;
transition: background 0.15s;
}
.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;
gap: 6px;
cursor: pointer;
font-size: 13px;
font-size: 0.8125rem;
}
.zone-mode-option input[type="radio"] { margin: 0; }
@@ -629,7 +629,7 @@ body.cs-drag-active .card-drag-handle {
.display-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 15px;
}
@@ -676,7 +676,10 @@ body.cs-drag-active .card-drag-handle {
.layout-display.primary {
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 {
@@ -768,7 +771,7 @@ body.cs-drag-active .card-drag-handle {
margin: 0 0 15px 0;
line-height: 1.5;
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-radius: 0 6px 6px 0;
}
@@ -939,13 +942,13 @@ ul.section-tip li {
transition: flex 0.3s ease;
}
.timing-extract { background: #4CAF50; }
.timing-extract { background: var(--primary-color); }
.timing-map { background: #FF9800; }
.timing-smooth { background: #2196F3; }
.timing-smooth { background: var(--info-color, #2196F3); }
.timing-send { background: #E91E63; }
.timing-audio-read { background: #4CAF50; }
.timing-audio-read { background: var(--primary-color); }
.timing-audio-fft { background: #FF9800; }
.timing-audio-render { background: #2196F3; }
.timing-audio-render { background: var(--info-color, #2196F3); }
.timing-legend {
display: flex;
@@ -969,13 +972,13 @@ ul.section-tip li {
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-smooth { background: #2196F3; }
.timing-dot.timing-smooth { background: var(--info-color, #2196F3); }
.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-render { background: #2196F3; }
.timing-dot.timing-audio-render { background: var(--info-color, #2196F3); }
@media (max-width: 768px) {
.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 {
margin-bottom: 15px;
}
@@ -60,7 +38,7 @@
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
@@ -182,7 +160,7 @@ select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
@@ -351,7 +329,7 @@ input:-webkit-autofill:focus {
.overlay-preview-img {
max-width: 80vw;
max-height: 50vh;
border-radius: 8px;
border-radius: var(--radius-md);
margin-top: 16px;
object-fit: contain;
}
@@ -369,13 +347,13 @@ input:-webkit-autofill:focus {
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 16px 24px;
border-radius: 8px;
border-radius: var(--radius-md);
color: white;
font-weight: 600;
font-size: 15px;
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);
z-index: 2500;
z-index: 3000;
box-shadow: 0 4px 20px var(--shadow-color);
min-width: 300px;
text-align: center;
@@ -424,7 +402,7 @@ input:-webkit-autofill:focus {
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
padding: 1px 7px;
border-radius: 8px;
border-radius: var(--radius-md);
white-space: nowrap;
line-height: 1.4;
}
@@ -439,7 +417,7 @@ input:-webkit-autofill:focus {
gap: 4px;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--bg-color);
cursor: text;
min-height: 38px;
@@ -501,7 +479,7 @@ input:-webkit-autofill:focus {
z-index: 1000;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--shadow-color);
margin-top: 4px;
max-height: 200px;
@@ -538,7 +516,11 @@ input:-webkit-autofill:focus {
.dashboard-action-btn:focus-visible,
.btn-expand-collapse: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-offset: 2px;
}
@@ -586,7 +568,7 @@ textarea:focus-visible {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
@@ -720,7 +702,7 @@ textarea:focus-visible {
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
border-radius: var(--radius-lg);
box-shadow: 0 16px 48px var(--shadow-color);
padding: 16px;
opacity: 0;
@@ -790,7 +772,7 @@ textarea:focus-visible {
max-height: 60vh;
background: var(--bg-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);
display: flex;
flex-direction: column;
@@ -878,7 +860,7 @@ textarea:focus-visible {
background: var(--bg-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 10px;
font-size: 1rem;
cursor: pointer;

View File

@@ -190,12 +190,12 @@ h2 {
.health-dot.health-online {
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 {
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 {
@@ -565,9 +565,23 @@ h2 {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4caf50;
background: var(--primary-color);
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,

View File

@@ -219,6 +219,11 @@
padding-bottom: 64px;
}
/* Graph editor: account for fixed bottom tab bar (~64px) */
.graph-container {
height: calc(100vh - var(--header-height, 60px) - 74px);
}
/* ── Container ── */
.container {
padding: 8px;
@@ -441,6 +446,9 @@
gap: 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 {
font-size: 1.3em;
color: #4caf50;
color: var(--primary-text-color);
}
.vs-test-status {
@@ -149,7 +149,7 @@
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 16px;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
@@ -333,6 +333,38 @@
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 */
.css-test-led-control {
display: flex;
@@ -391,7 +423,7 @@
.modal-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
max-height: calc(100vh - 40px);
@@ -562,8 +594,8 @@
.hint-toggle.active {
opacity: 1;
color: var(--primary-text-color, #4CAF50);
border-color: var(--primary-color, #4CAF50);
color: var(--primary-text-color);
border-color: var(--primary-color);
}
.input-hint {
@@ -695,6 +727,17 @@
min-width: auto;
}
.select-with-action {
display: flex;
gap: 6px;
align-items: center;
}
.select-with-action select {
flex: 1;
min-width: 0;
}
.fps-hint {
display: block;
margin-top: 4px;
@@ -767,7 +810,7 @@
}
.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);
color: var(--danger-color);
padding: 12px;
@@ -805,7 +848,7 @@
.btn-display-picker:hover {
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 {
@@ -983,14 +1026,14 @@
}
.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;
}
.layout-display-pickable.selected {
border-color: var(--primary-color) !important;
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
background: rgba(76, 175, 80, 0.12) !important;
box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 50%, transparent); /* --primary-color glow */
background: color-mix(in srgb, var(--primary-color) 12%, transparent) !important; /* --primary-color tint */
}
/* ── Device picker list (cameras, scrcpy) ─────────────────────── */
@@ -1133,6 +1176,33 @@
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-colors-list {
@@ -1211,6 +1281,71 @@
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-layers-list {
@@ -1269,3 +1404,58 @@
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 {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 16px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
display: flex;
@@ -92,20 +92,20 @@
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.template-description {
color: var(--text-secondary);
font-size: 14px;
font-size: 0.875rem;
margin-bottom: 12px;
line-height: 1.4;
}
.template-config {
font-size: 14px;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
}
@@ -166,7 +166,7 @@
.template-no-config {
margin: 12px 0;
font-size: 13px;
font-size: 0.8125rem;
color: var(--primary-text-color);
font-weight: 500;
padding: 4px 0;
@@ -176,7 +176,7 @@
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 13px;
font-size: 0.8125rem;
}
.config-table td {
@@ -246,7 +246,7 @@
.text-muted {
color: var(--text-secondary);
font-style: italic;
font-size: 13px;
font-size: 0.8125rem;
}
/* PP Filter List in Template Modal */
@@ -259,7 +259,7 @@
.pp-filter-empty {
color: var(--text-secondary);
font-size: 13px;
font-size: 0.8125rem;
text-align: center;
padding: 16px;
border: 1px dashed var(--border-color);
@@ -294,13 +294,13 @@
.pp-filter-card-name {
font-weight: 600;
font-size: 14px;
font-size: 0.875rem;
color: var(--text-primary);
}
.pp-filter-card-summary {
color: var(--text-secondary);
font-size: 12px;
font-size: 0.75rem;
margin-right: 8px;
white-space: nowrap;
overflow: hidden;
@@ -360,7 +360,7 @@
.pp-filter-option label {
display: flex;
justify-content: space-between;
font-size: 12px;
font-size: 0.75rem;
color: var(--text-secondary);
}
@@ -375,55 +375,21 @@
border-radius: 4px;
background: var(--card-bg);
color: var(--text-primary);
font-size: 12px;
font-size: 0.75rem;
font-family: monospace;
}
.pp-filter-option-bool label {
justify-content: space-between;
gap: 8px;
/* Bool option row: label text on left, .settings-toggle on right */
.pp-filter-option-bool {
flex-direction: row;
align-items: center;
cursor: pointer;
justify-content: space-between;
padding: 4px 0;
}
.pp-filter-option-bool input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
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-option-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* ── PP filter drag-and-drop ── */
@@ -432,7 +398,7 @@
cursor: grab;
opacity: 0;
color: var(--text-secondary);
font-size: 12px;
font-size: 0.75rem;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
@@ -492,7 +458,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
border-radius: 6px;
background: var(--card-bg);
color: var(--text-primary);
font-size: 13px;
font-size: 0.8125rem;
}
.pp-add-filter-btn {
@@ -506,7 +472,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
border-radius: 6px;
background: var(--card-bg);
color: var(--text-primary);
font-size: 20px;
font-size: 1.25rem;
cursor: pointer;
padding: 0;
line-height: 1;
@@ -526,7 +492,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.template-test-section h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 16px;
font-size: 1rem;
}
.test-results-container {
@@ -550,7 +516,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.test-performance-section h4 {
margin-top: 0;
margin-bottom: 12px;
font-size: 14px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
}
@@ -579,7 +545,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
font-size: 14px;
font-size: 0.875rem;
}
.stat-item:last-child {
@@ -602,7 +568,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
font-size: 16px;
font-size: 1rem;
}
/* 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 {
padding: 4px 0 0 0;
font-size: 14px;
font-size: 0.875rem;
line-height: 1.6;
}
@@ -792,23 +758,23 @@ body.pp-filter-dragging .pp-filter-drag-handle {
flex-shrink: 0;
}
.cs-filter {
.cs-filter-wrap .cs-filter {
width: 100%;
padding: 4px 26px 4px 10px !important;
font-size: 0.78rem !important;
border: 1px solid var(--border-color) !important;
border-radius: 14px !important;
background: var(--bg-secondary) !important;
color: var(--text-color) !important;
padding: 4px 26px 4px 10px;
font-size: 0.78rem;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
color: var(--text-color);
outline: none;
box-shadow: none !important;
box-shadow: none;
box-sizing: border-box;
transition: border-color 0.2s, background 0.2s, width 0.2s;
}
.cs-filter:focus {
border-color: var(--primary-color) !important;
background: var(--bg-color) !important;
.cs-filter-wrap .cs-filter:focus {
border-color: var(--primary-color);
background: var(--bg-color);
}
.cs-filter::placeholder {
@@ -838,6 +804,22 @@ body.pp-filter-dragging .pp-filter-drag-handle {
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 */
@media (max-width: 768px) {
.templates-grid {

View File

@@ -102,6 +102,7 @@ import {
import {
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
cloneDevice,
} from './features/device-discovery.js';
import {
loadTargetsTab, switchTargetSubTab,
@@ -123,6 +124,10 @@ import {
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
applyGradientPreset,
onGradientPresetChange,
promptAndSaveGradientPreset,
applyCustomGradientPreset,
deleteAndRefreshGradientPreset,
cloneColorStrip,
toggleCSSOverlay,
previewCSSFromEditor,
@@ -130,6 +135,7 @@ import {
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
} from './features/color-strips.js';
@@ -138,6 +144,7 @@ import {
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
editAudioSource, cloneAudioSource, deleteAudioSource,
testAudioSource, closeTestAudioSourceModal,
refreshAudioDevices,
} from './features/audio-sources.js';
// Layer 5: value sources
@@ -177,6 +184,11 @@ import { openCommandPalette, closeCommandPalette, initCommandPalette } from './c
import {
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
restartServer, saveMqttSettings,
loadApiKeysList,
downloadPartialExport, handlePartialImportFileSelected,
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
loadLogLevel, setLogLevel,
} from './features/settings.js';
// ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -240,6 +252,7 @@ Object.assign(window, {
loadDevices,
updateSettingsBaudFpsHint,
copyWsUrl,
cloneDevice,
// dashboard
loadDashboard,
@@ -424,6 +437,10 @@ Object.assign(window, {
mappedRemoveZone,
onAudioVizChange,
applyGradientPreset,
onGradientPresetChange,
promptAndSaveGradientPreset,
applyCustomGradientPreset,
deleteAndRefreshGradientPreset,
cloneColorStrip,
toggleCSSOverlay,
previewCSSFromEditor,
@@ -431,6 +448,7 @@ Object.assign(window, {
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
// audio sources
@@ -442,6 +460,7 @@ Object.assign(window, {
deleteAudioSource,
testAudioSource,
closeTestAudioSourceModal,
refreshAudioDevices,
// value sources
showValueSourceModal,
@@ -504,7 +523,7 @@ Object.assign(window, {
openCommandPalette,
closeCommandPalette,
// settings (backup / restore / auto-backup)
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
openSettingsModal,
closeSettingsModal,
downloadBackup,
@@ -513,6 +532,17 @@ Object.assign(window, {
restoreSavedBackup,
downloadSavedBackup,
deleteSavedBackup,
restartServer,
saveMqttSettings,
loadApiKeysList,
downloadPartialExport,
handlePartialImportFileSelected,
connectLogViewer,
disconnectLogViewer,
clearLogViewer,
applyLogFilter,
loadLogLevel,
setLogLevel,
});
// ─── 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.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.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.titleKey = titleKey;
this.gridClass = gridClass;
@@ -54,6 +55,7 @@ export class CardSection {
this.keyAttr = keyAttr || '';
this.headerExtra = headerExtra || '';
this.collapsible = !!collapsible;
this.emptyKey = emptyKey || '';
this._filterValue = '';
this._lastItems = 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>`
: '';
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 `
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
@@ -99,7 +105,7 @@ export class CardSection {
</div>
</div>
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
${cardsHtml}
${emptyState}${cardsHtml}
${addCard}
</div>
</div>`;
@@ -205,6 +211,25 @@ export class CardSection {
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
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 addCard = content.querySelector('.cs-add-card');
const added = new Set();

View File

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

View File

@@ -8,10 +8,11 @@ import { navigateToCard } from './navigation.js';
import {
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
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';
import { getCardColor } from './card-colors.js';
import { graphNavigateToNode } from '../features/graph-editor.js';
import { showToast } from './ui.js';
let _isOpen = false;
let _items = [];
@@ -33,7 +34,7 @@ function _mapEntities(data, mapFn) {
}
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 = [];
_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,
});
}
// 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({
@@ -61,10 +82,31 @@ function _buildItems(results, states = {}) {
nav: ['streams', 'color_strip', 'color-strips', 'data-css-id', c.id],
}));
_mapEntities(automations, a => items.push({
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
}));
_mapEntities(automations, a => {
items.push({
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({
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({
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
}));
_mapEntities(scenePresets, sp => {
items.push({
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({
name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT,
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;
}
@@ -129,6 +186,7 @@ const _responseKeys = [
['/picture-sources', 'streams'],
['/scene-presets', 'presets'],
['/color-strip-processing-templates', 'templates'],
['/sync-clocks', 'clocks'],
];
async function _fetchAllEntities() {
@@ -149,9 +207,10 @@ async function _fetchAllEntities() {
// ─── Group ordering ───
const _groupOrder = [
'actions',
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
'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]));
@@ -204,9 +263,11 @@ function _render() {
html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`;
for (const item of items) {
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}"` : '';
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-name">${escapeHtml(item.name)}</span>` +
(item.running ? '<span class="cp-running"></span>' : '') +
@@ -307,6 +368,12 @@ function _selectCurrent() {
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
const item = _filtered[_selectedIdx];
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
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
if (graphTabActive) {

View File

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

View File

@@ -42,7 +42,7 @@ class AutomationEditorModal extends Modal {
}
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 ───────────────────────────────── */

View File

@@ -13,7 +13,7 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
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';
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
@@ -25,10 +25,12 @@ import {
rgbArrayToHex, hexToRgbArray,
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
} from './css-gradient-editor.js';
// Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };
class CSSEditorModal extends Modal {
constructor() {
@@ -173,7 +175,7 @@ export function onCSSTypeChange() {
_ensureAudioPaletteIconSelect();
onAudioVizChange();
}
if (type === 'gradient') _ensureGradientPresetIconSelect();
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
if (type === 'notification') {
_ensureNotificationEffectIconSelect();
_ensureNotificationFilterModeIconSelect();
@@ -415,19 +417,64 @@ function _ensureAudioVizIconSelect() {
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureGradientPresetIconSelect() {
const sel = document.getElementById('css-editor-gradient-preset');
if (!sel) return;
const items = [
function _buildGradientPresetItems() {
const builtIn = [
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
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; }
_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() {
const sel = document.getElementById('css-editor-notification-effect');
if (!sel) return;
@@ -473,6 +520,40 @@ function _buildAnimationTypeItems(cssType) {
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) {
const sel = document.getElementById('css-editor-animation-type');
if (!sel) return;
@@ -650,8 +731,9 @@ function _compositeRenderList() {
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item">
<div class="composite-layer-item" data-layer-index="${i}">
<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-blend" data-idx="${i}">
<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'),
}));
});
_initCompositeLayerDrag(list);
}
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() {
_compositeLayersSyncFromDom();
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() {
const list = document.getElementById('notification-app-colors-list');
if (!list) return;
@@ -1353,6 +1653,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
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>`
: '';
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
? `<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: `
<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>
${calibrationBtn}${overlayBtn}${testNotifyBtn}${testPreviewBtn}`,
${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
});
}

View File

@@ -378,6 +378,41 @@ function _gradientStartDrag(e, idx) {
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 ───────────────────────────────────── */
function _gradientSetupTrackClick() {

View File

@@ -5,7 +5,7 @@
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 { 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 { startAutoRefresh, updateTabBadge } from './tabs.js';
import {
@@ -733,6 +733,8 @@ export async function dashboardStopTarget(targetId) {
}
export async function dashboardStopAll() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
try {
const [allTargets, statesResp] = await Promise.all([
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
if (!presetType) {
showTypePicker({
@@ -623,6 +623,47 @@ export function showAddDevice(presetType = null) {
addDeviceModal.open();
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(() => {
desktopFocus(document.getElementById('device-name'));
addDeviceModal.snapshot();
@@ -984,3 +1025,18 @@ function _showGameSenseFields(show) {
const el = document.getElementById('device-gamesense-device-type-group');
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 { showToast, showConfirm, desktopFocus } from '../core/ui.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 { TagInput, renderTagChips } from '../core/tag-input.js';
import { EntitySelect } from '../core/entity-palette.js';
@@ -95,6 +95,22 @@ class DeviceSettingsModal extends Modal {
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) {
const state = device.state || {};
@@ -124,6 +140,7 @@ export function createDeviceCard(device) {
}
const ledCount = state.device_led_count || device.led_count;
const lastSeenLabel = devLastChecked ? _formatRelativeTime(devLastChecked) : null;
// Parse zone names from OpenRGB URL for badge display
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>` : ''}
<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>
${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') ? `
<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"
@@ -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')}">
${ICON_REFRESH}
</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')}">
${ICON_SETTINGS}
</button>`,
@@ -173,6 +194,8 @@ export function createDeviceCard(device) {
}
export async function turnOffDevice(deviceId) {
const confirmed = await showConfirm(t('confirm.turn_off_device'));
if (!confirmed) return;
try {
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
method: 'PUT',

View File

@@ -11,7 +11,7 @@ import { CardSection } from '../core/card-sections.js';
import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
} 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 { cardColorStyle, cardColorButton } from '../core/card-colors.js';
import { EntityPalette } from '../core/entity-palette.js';
@@ -43,13 +43,18 @@ export const csScenes = new CardSection('scenes', {
gridClass: 'devices-grid',
addCardOnclick: "openScenePresetCapture()",
keyAttr: 'data-scene-id',
emptyKey: 'section.empty.scenes',
});
export function createSceneCard(preset) {
const targetCount = (preset.targets || []).length;
const automations = automationsCacheObj.data || [];
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
usedByCount > 0 ? `🔗 ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
].filter(Boolean);
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 { 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)
const settingsModal = new Modal('settings-modal');
export function openSettingsModal() {
document.getElementById('settings-error').style.display = 'none';
settingsModal.open();
loadApiKeysList();
loadAutoBackupSettings();
loadBackupList();
loadMqttSettings();
loadLogLevel();
}
export function closeSettingsModal() {
disconnectLogViewer();
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 ───────────────────────────────────────
function showRestartOverlay() {
function showRestartOverlay(message) {
const msg = message || t('settings.restore.restarting');
const overlay = document.createElement('div');
overlay.id = 'restart-overlay';
overlay.style.cssText =
@@ -101,7 +244,7 @@ function showRestartOverlay() {
overlay.innerHTML =
'<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>' +
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`;
`<div id="restart-msg">${msg}</div>`;
// Add spinner animation if not present
if (!document.getElementById('restart-spinner-style')) {
@@ -201,12 +344,20 @@ export async function loadBackupList() {
}
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 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;">
${typeBadge}
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
<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>
<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>
@@ -299,3 +450,192 @@ export async function deleteSavedBackup(filename) {
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 { t } from '../core/i18n.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 { CardSection } from '../core/card-sections.js';
import { TreeNav } from '../core/tree-nav.js';
@@ -70,19 +70,19 @@ let _audioTemplateTagsInput = null;
let _csptTagsInput = null;
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-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', 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', emptyKey: 'section.empty.picture_sources' });
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', 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', 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', 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', 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', 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', emptyKey: 'section.empty.color_strips' });
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', 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', emptyKey: 'section.empty.cspt' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -322,6 +322,7 @@ export async function showTestTemplateModal(templateId) {
restoreCaptureDuration();
testTemplateModal.open();
setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal());
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
@@ -2162,6 +2163,7 @@ export async function showTestStreamModal(streamId) {
restoreStreamTestDuration();
testStreamModal.open();
setupBackdropClose(testStreamModal.el, () => closeTestStreamModal());
}
export function closeTestStreamModal() {
@@ -2229,6 +2231,7 @@ export async function showTestPPTemplateModal(templateId) {
});
testPPTemplateModal.open();
setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal());
}
export function closeTestPPTemplateModal() {

View File

@@ -190,6 +190,15 @@ export async function resetSyncClock(clockId) {
// ── 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) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
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}')`
: `resumeSyncClock('${clock.id}')`;
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({
type: 'template-card',
@@ -211,6 +221,7 @@ export function createSyncClockCard(clock) {
<div class="stream-card-props">
<span class="stream-card-prop">${statusIcon} ${statusLabel}</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>
${renderTagChips(clock.tags)}
${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)
// ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
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 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 csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-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', 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', 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', emptyKey: 'section.empty.pattern_templates' });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -189,8 +189,12 @@ function _updateSpecificSettingsVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
// Hide entire Specific Settings section for non-WLED devices (protocol + keepalive are WLED-only)
document.getElementById('target-editor-device-settings').style.display = isWled ? '' : 'none';
// Hide WLED-only controls (protocol + keepalive) for non-WLED devices
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() {
@@ -1069,10 +1073,14 @@ export async function stopTargetProcessing(targetId) {
}
export async function stopAllLedTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors');
}

View File

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

View File

@@ -134,6 +134,88 @@ function _ensureWaveformIconSelect() {
_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 ────────────────────────────── */
const _AUDIO_MODE_SVG = {
@@ -208,6 +290,7 @@ export async function showValueSourceModal(editData, presetType = null) {
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
_drawWaveformPreview(editData.waveform || 'sine');
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_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-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine';
_drawWaveformPreview('sine');
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
@@ -274,7 +358,7 @@ export async function showValueSourceModal(editData, presetType = null) {
}
// 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-picture-source').onchange = () => _autoGenerateVSName();
@@ -296,7 +380,7 @@ export function onValueSourceTypeChange() {
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : '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';
if (type === 'audio') _ensureAudioModeIconSelect();
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_enter": "Enter your API key:",
"auth.toggle_password": "Toggle password visibility",
"api_key.login": "Login",
"displays.title": "Available Displays",
"displays.layout": "Displays",
"displays.information": "Display Information",
@@ -291,6 +292,12 @@
"device.health.offline": "Offline",
"device.health.streaming_unreachable": "Unreachable during streaming",
"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.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness",
@@ -405,6 +412,8 @@
"confirm.title": "Confirm Action",
"confirm.yes": "Yes",
"confirm.no": "No",
"confirm.stop_all": "Stop all running targets?",
"confirm.turn_off_device": "Turn off this device?",
"common.loading": "Loading...",
"common.delete": "Delete",
"common.edit": "Edit",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "Color Strip Sources",
"targets.section.targets": "Targets",
"targets.section.specific_settings": "Specific Settings",
"targets.section.advanced": "Advanced",
"targets.add": "Add Target",
"targets.edit": "Edit Target",
"targets.loading": "Loading targets...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "Cool",
"color_strip.gradient.preset.neon": "Neon",
"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.type": "Effect:",
"color_strip.animation.type.hint": "Animation effect to apply.",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "Notification sent",
"color_strip.notification.test.no_streams": "No running streams for this source",
"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.connecting": "Connecting...",
"color_strip.test.error": "Failed to connect to preview stream",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "Mono",
"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.refresh_devices": "Refresh devices",
"audio_source.parent": "Parent Source:",
"audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:",
@@ -1375,6 +1400,13 @@
"search.group.value": "Value Sources",
"search.group.scenes": "Scene Presets",
"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.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
"settings.backup.button": "Download Backup",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "Restore failed",
"settings.restore.restarting": "Server is restarting...",
"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.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.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",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "Delete",
"settings.saved_backups.delete_confirm": "Delete this backup file?",
"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.removed": "Device removed",
"device.error.remove_failed": "Failed to remove device",
@@ -1415,6 +1481,7 @@
"device.error.required": "Please fill in all fields correctly",
"device.error.update": "Failed to update device",
"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.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "Clock resumed",
"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.elapsed": "Elapsed time",
"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.",
"graph.title": "Graph",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "Detach connection",
"automation.enabled": "Automation enabled",
"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.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "Войти",
"displays.title": "Доступные Дисплеи",
"displays.layout": "Дисплеи",
"displays.information": "Информация о Дисплеях",
@@ -291,6 +292,12 @@
"device.health.offline": "Недоступен",
"device.health.streaming_unreachable": "Недоступен во время стриминга",
"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.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости",
@@ -402,9 +409,11 @@
"error.network": "Сетевая ошибка",
"error.unknown": "Произошла ошибка",
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
"confirm.title": "Подтверждение Действия",
"confirm.title": "Подтверждение",
"confirm.yes": "Да",
"confirm.no": "Нет",
"confirm.stop_all": "Остановить все запущенные цели?",
"confirm.turn_off_device": "Выключить это устройство?",
"common.loading": "Загрузка...",
"common.delete": "Удалить",
"common.edit": "Редактировать",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "Источники цветовых полос",
"targets.section.targets": "Цели",
"targets.section.specific_settings": "Специальные настройки",
"targets.section.advanced": "Расширенные",
"targets.add": "Добавить Цель",
"targets.edit": "Редактировать Цель",
"targets.loading": "Загрузка целей...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "Холодный",
"color_strip.gradient.preset.neon": "Неон",
"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.type": "Эффект:",
"color_strip.animation.type.hint": "Эффект анимации.",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "Уведомление отправлено",
"color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника",
"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.connecting": "Подключение...",
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "Моно",
"audio_source.device": "Аудиоустройство:",
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
"audio_source.refresh_devices": "Обновить устройства",
"audio_source.parent": "Родительский источник:",
"audio_source.parent.hint": "Многоканальный источник для извлечения канала",
"audio_source.channel": "Канал:",
@@ -1375,6 +1400,13 @@
"search.group.value": "Источники значений",
"search.group.scenes": "Пресеты сцен",
"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.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "Ошибка восстановления",
"settings.restore.restarting": "Сервер перезапускается...",
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
"settings.restart_server": "Перезапустить сервер",
"settings.restart_confirm": "Перезапустить сервер? Активные цели будут остановлены.",
"settings.restarting": "Перезапуск сервера...",
"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.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "Удалить",
"settings.saved_backups.delete_confirm": "Удалить эту резервную копию?",
"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.removed": "Устройство удалено",
"device.error.remove_failed": "Не удалось удалить устройство",
@@ -1415,6 +1481,7 @@
"device.error.required": "Пожалуйста, заполните все поля",
"device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки",
"device.error.clone_failed": "Не удалось клонировать устройство",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "Часы возобновлены",
"sync_clock.reset_done": "Часы сброшены на ноль",
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
"sync_clock.elapsed": "Прошло времени",
"color_strip.clock": "Часы синхронизации:",
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
"graph.title": "Граф",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "Отсоединить связь",
"automation.enabled": "Автоматизация включена",
"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.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "登录",
"displays.title": "可用显示器",
"displays.layout": "显示器",
"displays.information": "显示器信息",
@@ -291,6 +292,12 @@
"device.health.offline": "离线",
"device.health.streaming_unreachable": "流传输期间不可达",
"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.tip.metadata": "设备信息LED 数量、类型、颜色通道)从设备自动检测",
"device.tip.brightness": "滑动调节设备亮度",
@@ -405,6 +412,8 @@
"confirm.title": "确认操作",
"confirm.yes": "是",
"confirm.no": "否",
"confirm.stop_all": "停止所有运行中的目标?",
"confirm.turn_off_device": "关闭此设备?",
"common.loading": "加载中...",
"common.delete": "删除",
"common.edit": "编辑",
@@ -584,6 +593,7 @@
"targets.section.color_strips": "色带源",
"targets.section.targets": "目标",
"targets.section.specific_settings": "特定设置",
"targets.section.advanced": "高级",
"targets.add": "添加目标",
"targets.edit": "编辑目标",
"targets.loading": "正在加载目标...",
@@ -953,6 +963,11 @@
"color_strip.gradient.preset.cool": "冷色",
"color_strip.gradient.preset.neon": "霓虹",
"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.type": "效果:",
"color_strip.animation.type.hint": "要应用的动画效果。",
@@ -1043,6 +1058,15 @@
"color_strip.notification.test.ok": "通知已发送",
"color_strip.notification.test.no_streams": "此源没有运行中的流",
"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.connecting": "连接中...",
"color_strip.test.error": "无法连接到预览流",
@@ -1188,6 +1212,7 @@
"audio_source.type.mono": "单声道",
"audio_source.device": "音频设备:",
"audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。",
"audio_source.refresh_devices": "刷新设备",
"audio_source.parent": "父源:",
"audio_source.parent.hint": "要从中提取通道的多声道源",
"audio_source.channel": "通道:",
@@ -1375,6 +1400,13 @@
"search.group.value": "值源",
"search.group.scenes": "场景预设",
"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.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份",
@@ -1388,7 +1420,15 @@
"settings.restore.error": "恢复失败",
"settings.restore.restarting": "服务器正在重启...",
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
"settings.restart_server": "重启服务器",
"settings.restart_confirm": "重启服务器?活跃的目标将被停止。",
"settings.restarting": "正在重启服务器...",
"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.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份",
@@ -1407,6 +1447,32 @@
"settings.saved_backups.delete": "删除",
"settings.saved_backups.delete_confirm": "删除此备份文件?",
"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.removed": "设备已移除",
"device.error.remove_failed": "移除设备失败",
@@ -1415,6 +1481,7 @@
"device.error.required": "请填写所有字段",
"device.error.update": "更新设备失败",
"device.error.save": "保存设置失败",
"device.error.clone_failed": "克隆设备失败",
"device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败",
@@ -1506,6 +1573,7 @@
"sync_clock.resumed": "时钟已恢复",
"sync_clock.reset_done": "时钟已重置为零",
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
"sync_clock.elapsed": "已用时间",
"color_strip.clock": "同步时钟:",
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
"graph.title": "图表",
@@ -1570,5 +1638,50 @@
"graph.help.right_click_desc": "断开连接",
"automation.enabled": "自动化已启用",
"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": "暂无场景预设。点击 + 添加。"
}