Replace all emoji icons with Lucide SVGs, add accent color picker

- Replace all emoji characters across WebUI with inline Lucide SVG icons
  for cross-platform consistency (icon paths in icon-paths.js)
- Add accent color picker popover with 9 preset colors + custom picker,
  persisted to localStorage, updates all CSS custom properties
- Remove subtab separator line for cleaner look
- Color badge icons with accent color for visual pop
- Remove processing badge from target cards
- Fix hardcoded #4CAF50 in FPS labels and active badges to use CSS vars
- Replace CSS content emoji (▶) with pure CSS triangle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:14:18 +03:00
parent efb6cf7ce6
commit c262ec0775
39 changed files with 634 additions and 311 deletions

View File

@@ -6,9 +6,25 @@
:root { :root {
--primary-color: #4CAF50; --primary-color: #4CAF50;
--primary-hover: #5cb860;
--danger-color: #f44336; --danger-color: #f44336;
--warning-color: #ff9800; --warning-color: #ff9800;
--info-color: #2196F3; --info-color: #2196F3;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
}
/* ── SVG icon base ── */
.icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.125em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
} }
/* Dark theme (default) */ /* Dark theme (default) */

View File

@@ -312,6 +312,10 @@ body.cs-drag-active .card-drag-handle {
font-size: 0.6rem; font-size: 0.6rem;
} }
.device-url-badge .icon {
color: var(--primary-text-color);
}
.card-subtitle { .card-subtitle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -328,6 +332,10 @@ body.cs-drag-active .card-drag-handle {
gap: 4px; gap: 4px;
} }
.card-meta .icon {
color: var(--primary-text-color);
}
.device-type-badge { .device-type-badge {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -647,7 +655,7 @@ ul.section-tip li {
font-weight: 400; font-weight: 400;
opacity: 0.45; opacity: 0.45;
line-height: 1.1; line-height: 1.1;
color: #4CAF50; color: var(--primary-color);
} }
.fps-unreachable { .fps-unreachable {

View File

@@ -192,7 +192,7 @@
font-weight: 400; font-weight: 400;
opacity: 0.45; opacity: 0.45;
line-height: 1.1; line-height: 1.1;
color: #4CAF50; color: var(--primary-color);
} }
.dashboard-target-actions { .dashboard-target-actions {
@@ -258,7 +258,7 @@
border-radius: 10px; border-radius: 10px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
background: var(--success-color); background: var(--primary-color);
color: #fff; color: #fff;
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -216,6 +216,81 @@ h2 {
transform: scale(1.1); transform: scale(1.1);
} }
/* Accent color picker */
.accent-wrapper {
position: relative;
}
.accent-swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border-color);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-toggle:hover .accent-swatch {
box-shadow: 0 0 6px var(--primary-color);
}
.accent-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px;
box-shadow: 0 8px 24px var(--shadow-color);
z-index: 200;
animation: accent-pop-in 0.15s ease-out;
}
@keyframes accent-pop-in {
from { opacity: 0; transform: translateY(-4px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.accent-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.accent-dot {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid transparent;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
padding: 0;
}
.accent-dot:hover {
transform: scale(1.15);
box-shadow: 0 0 8px rgba(255,255,255,0.2);
}
.accent-dot.active {
border-color: var(--text-color);
box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--text-color);
}
.accent-custom {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
}
.accent-custom input[type="color"] {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 2px;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
/* Footer */ /* Footer */
.app-footer { .app-footer {
margin-top: 12px; margin-top: 12px;

View File

@@ -477,8 +477,13 @@
.form-collapse > summary::-webkit-details-marker { display: none; } .form-collapse > summary::-webkit-details-marker { display: none; }
.form-collapse > summary::before { .form-collapse > summary::before {
content: ''; content: '';
font-size: 0.6rem; display: inline-block;
width: 0;
height: 0;
border-left: 0.35em solid currentColor;
border-top: 0.25em solid transparent;
border-bottom: 0.25em solid transparent;
opacity: 0.6; opacity: 0.6;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0; flex-shrink: 0;

View File

@@ -54,6 +54,10 @@
vertical-align: middle; vertical-align: middle;
} }
.stream-card-prop .icon {
color: var(--primary-text-color);
}
.stream-card-prop-full { .stream-card-prop-full {
max-width: 100%; max-width: 100%;
word-break: break-all; word-break: break-all;
@@ -72,6 +76,10 @@
color: #fff; color: #fff;
} }
.stream-card-link:hover .icon {
color: #fff;
}
@keyframes cardHighlight { @keyframes cardHighlight {
0%, 100% { box-shadow: none; } 0%, 100% { box-shadow: none; }
25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); } 25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); }

View File

@@ -538,7 +538,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
border-bottom: 2px solid var(--border-color);
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -551,7 +550,6 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s ease, border-color 0.25s ease; transition: color 0.2s ease, border-color 0.25s ease;
} }

View File

@@ -0,0 +1,71 @@
/**
* Lucide icon SVG path data (https://lucide.dev) — MIT license.
*
* Each export is the inner SVG markup (paths, circles, rects, lines)
* for a 24×24 viewBox icon. These are consumed by icons.js via the
* _svg() wrapper which adds the outer <svg> tag with consistent attributes.
*
* To add a new icon: copy the inner elements from the Lucide source SVG
* and export as a single string constant here.
*/
export const lightbulb = '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>';
export const zap = '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>';
export const palette = '<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>';
export const monitor = '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>';
export const layoutDashboard = '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>';
export const clipboardList = '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>';
export const copy = '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>';
export const tv = '<path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/>';
export const film = '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>';
export const fileText = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>';
export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/>';
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
export const ruler = '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>';
export const volume2 = '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/><path d="M16 9a5 5 0 0 1 0 6"/><path d="M19.364 18.364a9 9 0 0 0 0-12.728"/>';
export const mic = '<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>';
export const clock = '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>';
export const triangleAlert = '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>';
export const circleCheck = '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>';
export const globe = '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>';
export const eye = '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>';
export const eyeOff = '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>';
export const star = '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>';
export const hash = '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>';
export const camera = '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>';
export const wrench = '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>';
export const music = '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>';
export const search = '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>';
export const moon = '<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>';
export const sun = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>';
export const keyRound = '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>';
export const logOut = '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>';
export const rainbow = '<path d="M22 17a10 10 0 0 0-20 0"/><path d="M6 17a6 6 0 0 1 12 0"/><path d="M10 17a2 2 0 0 1 4 0"/>';
export const refreshCw = '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>';
export const link = '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>';
export const mapPin = '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>';
export const plug = '<path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/>';
export const smartphone = '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>';
export const rocket = '<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09"/><path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05"/>';
export const image = '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>';
export const target = '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>';
export const trendingUp = '<path d="M16 7h6v6"/><path d="m22 7-8.5 8.5-5-5L2 17"/>';
export const activity = '<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>';
export const timer = '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>';
export const moveVertical = '<path d="M12 2v20"/><path d="m8 18 4 4 4-4"/><path d="m8 6 4-4 4 4"/>';
export const cloudSun = '<path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/>';
export const sunDim = '<circle cx="12" cy="12" r="4"/><path d="M12 4h.01"/><path d="M20 12h.01"/><path d="M12 20h.01"/><path d="M4 12h.01"/><path d="M17.657 6.343h.01"/><path d="M17.657 17.657h.01"/><path d="M6.343 17.657h.01"/><path d="M6.343 6.343h.01"/>';
export const slidersHorizontal = '<path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/>';
export const circleHelp = '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>';
export const radio = '<path d="M16.247 7.761a6 6 0 0 1 0 8.478"/><path d="M19.075 4.933a10 10 0 0 1 0 14.134"/><path d="M4.925 19.067a10 10 0 0 1 0-14.134"/><path d="M7.753 16.239a6 6 0 0 1 0-8.478"/><circle cx="12" cy="12" r="2"/>';
export const send = '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>';
export const sparkles = '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>';
export const fastForward = '<path d="M12 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 12 18z"/><path d="M2 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 2 18z"/>';
export const rotateCw = '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>';
export const rotateCcw = '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>';
export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>';
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';

View File

@@ -1,97 +1,146 @@
/** /**
* Centralized emoji icon maps and getter functions. * Centralized SVG icon constants and getter functions.
*
* Uses Lucide icons (https://lucide.dev) — MIT-licensed, 24×24 stroke icons.
* SVG path data lives in icon-paths.js; this module wraps them with the
* <svg> tag and exports named constants for use across the app.
* *
* Import icons from this module instead of using inline emoji literals. * Import icons from this module instead of using inline emoji literals.
*/ */
// ── Type-resolution maps (private) ────────────────────────── import * as P from './icon-paths.js';
const _targetTypeIcons = { led: '\uD83D\uDCA1', wled: '\uD83D\uDCA1', key_colors: '\uD83C\uDFA8' }; // ── SVG wrapper ────────────────────────────────────────────
const _pictureSourceTypeIcons = { raw: '\uD83D\uDDA5\uFE0F', processed: '\uD83C\uDFA8', static_image: '\uD83D\uDDBC\uFE0F' }; const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Type-resolution maps (private) ──────────────────────────
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image) };
const _colorStripTypeIcons = { const _colorStripTypeIcons = {
static: '\uD83C\uDFA8', color_cycle: '\uD83D\uDD04', gradient: '\uD83C\uDF08', static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
effect: '\u26A1', composite: '\uD83D\uDD17', effect: _svg(P.zap), composite: _svg(P.link),
mapped: '\uD83D\uDCCD', mapped_zones: '\uD83D\uDCCD', mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: '\uD83C\uDFB5', audio_visualization: '\uD83C\uDFB5', audio: _svg(P.music), audio_visualization: _svg(P.music),
api_input: '\uD83D\uDCE1', api_input: _svg(P.send),
}; };
const _valueSourceTypeIcons = { const _valueSourceTypeIcons = {
static: '\uD83D\uDCCA', animated: '\uD83D\uDD04', audio: '\uD83C\uDFB5', static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
adaptive_time: '\uD83D\uDD50', adaptive_scene: '\uD83C\uDF24\uFE0F', adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun),
}; };
const _audioSourceTypeIcons = { mono: '\uD83C\uDFA4', multichannel: '\uD83D\uDD0A' }; const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2) };
const _engineTypeIcons = { scrcpy: '\uD83D\uDCF1' }; const _engineTypeIcons = { scrcpy: _svg(P.smartphone) };
// ── Type-resolution getters ───────────────────────────────── // ── Type-resolution getters ─────────────────────────────────
/** Target type → emoji (fallback: ) */ /** Target type → icon (fallback: zap) */
export function getTargetTypeIcon(targetType) { export function getTargetTypeIcon(targetType) {
return _targetTypeIcons[targetType] || '\u26A1'; return _targetTypeIcons[targetType] || _svg(P.zap);
} }
/** Picture source / stream type → emoji (fallback: 📺) */ /** Picture source / stream type → icon (fallback: tv) */
export function getPictureSourceIcon(streamType) { export function getPictureSourceIcon(streamType) {
return _pictureSourceTypeIcons[streamType] || '\uD83D\uDCFA'; return _pictureSourceTypeIcons[streamType] || _svg(P.tv);
} }
/** Color strip source type → emoji (fallback: 🎞️) */ /** Color strip source type → icon (fallback: film) */
export function getColorStripIcon(sourceType) { export function getColorStripIcon(sourceType) {
return _colorStripTypeIcons[sourceType] || '\uD83C\uDF9E\uFE0F'; return _colorStripTypeIcons[sourceType] || _svg(P.film);
} }
/** Value source type → emoji (fallback: 🎚️) */ /** Value source type → icon (fallback: sliders) */
export function getValueSourceIcon(sourceType) { export function getValueSourceIcon(sourceType) {
return _valueSourceTypeIcons[sourceType] || '\uD83C\uDF9A\uFE0F'; return _valueSourceTypeIcons[sourceType] || _svg(P.slidersHorizontal);
} }
/** Audio source type → emoji (fallback: 🎵) */ /** Audio source type → icon (fallback: music) */
export function getAudioSourceIcon(sourceType) { export function getAudioSourceIcon(sourceType) {
return _audioSourceTypeIcons[sourceType] || '\uD83C\uDFB5'; return _audioSourceTypeIcons[sourceType] || _svg(P.music);
} }
/** Capture engine type → emoji (fallback: 🚀) */ /** Capture engine type → icon (fallback: rocket) */
export function getEngineIcon(engineType) { export function getEngineIcon(engineType) {
return _engineTypeIcons[engineType] || '\uD83D\uDE80'; return _engineTypeIcons[engineType] || _svg(P.rocket);
} }
// ── Entity-kind constants ─────────────────────────────────── // ── Entity-kind constants ───────────────────────────────────
export const ICON_PROFILE = '\uD83D\uDCCB'; // 📋 export const ICON_PROFILE = _svg(P.clipboardList);
export const ICON_DEVICE = '\uD83D\uDDA5\uFE0F'; // 🖥️ export const ICON_DEVICE = _svg(P.monitor);
export const ICON_TARGET = '\u26A1'; // ⚡ export const ICON_TARGET = _svg(P.zap);
export const ICON_VALUE_SOURCE = '\uD83D\uDD22'; // 🔢 export const ICON_VALUE_SOURCE = _svg(P.hash);
// ── Template-kind constants ───────────────────────────────── // ── Template-kind constants ─────────────────────────────────
export const ICON_TEMPLATE = '\uD83D\uDCCB'; // 📋 (generic card header) export const ICON_TEMPLATE = _svg(P.clipboardList);
export const ICON_CAPTURE_TEMPLATE = '\uD83D\uDCF7'; // 📷 export const ICON_CAPTURE_TEMPLATE = _svg(P.camera);
export const ICON_PP_TEMPLATE = '\uD83D\uDD27'; // 🔧 export const ICON_PP_TEMPLATE = _svg(P.wrench);
export const ICON_PATTERN_TEMPLATE = '\uD83D\uDCC4'; // 📄 export const ICON_PATTERN_TEMPLATE = _svg(P.fileText);
export const ICON_AUDIO_TEMPLATE = '\uD83C\uDFB5'; // 🎵 export const ICON_AUDIO_TEMPLATE = _svg(P.music);
// ── Action constants ──────────────────────────────────────── // ── Action constants ────────────────────────────────────────
export const ICON_CLONE = '\uD83D\uDCCB'; // 📋 export const ICON_CLONE = _svg(P.copy);
export const ICON_EDIT = '\u270F\uFE0F'; // ✏️ export const ICON_EDIT = _svg(P.pencil);
export const ICON_TEST = '\uD83E\uDDEA'; // 🧪 export const ICON_TEST = _svg(P.flaskConical);
export const ICON_START = '\u25B6\uFE0F'; // ▶️ export const ICON_START = _svg(P.play);
export const ICON_STOP = '\u23F9\uFE0F'; // ⏹️ export const ICON_STOP = _svg(P.square);
export const ICON_STOP_PLAIN = '\u23F9'; // ⏹ export const ICON_STOP_PLAIN = _svg(P.square);
export const ICON_PAUSE = '\u23F8'; // ⏸ export const ICON_PAUSE = _svg(P.pause);
export const ICON_SETTINGS = '\u2699\uFE0F'; // ⚙️ export const ICON_SETTINGS = _svg(P.settings);
export const ICON_CALIBRATION = '\uD83D\uDCD0'; // 📐 export const ICON_CALIBRATION = _svg(P.ruler);
// ── Misc badge constants ──────────────────────────────────── // ── Misc badge constants ────────────────────────────────────
export const ICON_AUDIO_LOOPBACK = '\uD83D\uDD0A'; // 🔊 export const ICON_AUDIO_LOOPBACK = _svg(P.volume2);
export const ICON_AUDIO_INPUT = '\uD83C\uDFA4'; // 🎤 export const ICON_AUDIO_INPUT = _svg(P.mic);
export const ICON_CLOCK = '\uD83D\uDD50'; // 🕐 export const ICON_CLOCK = _svg(P.clock);
export const ICON_WARNING = '\u26A0\uFE0F'; // ⚠️ export const ICON_WARNING = _svg(P.triangleAlert);
export const ICON_OK = '\u2705'; // ✅ export const ICON_OK = _svg(P.circleCheck);
export const ICON_LINK_SOURCE = '\uD83D\uDCFA'; // 📺 export const ICON_LINK_SOURCE = _svg(P.tv);
export const ICON_LED = '\uD83D\uDCA1'; // 💡 export const ICON_LED = _svg(P.lightbulb);
export const ICON_FPS = '\u26A1'; // ⚡ export const ICON_FPS = _svg(P.zap);
export const ICON_WEB = '\uD83C\uDF10'; // 🌐 export const ICON_WEB = _svg(P.globe);
export const ICON_OVERLAY = '\uD83D\uDC41\uFE0F'; // 👁️ export const ICON_OVERLAY = _svg(P.eye);
export const ICON_LED_PREVIEW = '\uD83D\uDCCA'; // 📊 export const ICON_LED_PREVIEW = _svg(P.layoutDashboard);
export const ICON_AUTOSTART = '\u2B50'; // ⭐ export const ICON_AUTOSTART = _svg(P.star);
// ── UI / header / modal icons ───────────────────────────────
export const ICON_SEARCH = _svg(P.search);
export const ICON_MOON = _svg(P.moon);
export const ICON_SUN = _svg(P.sun);
export const ICON_KEY = _svg(P.keyRound);
export const ICON_LOGOUT = _svg(P.logOut);
export const ICON_EYE = _svg(P.eye);
export const ICON_EYE_OFF = _svg(P.eyeOff);
export const ICON_HELP = _svg(P.circleHelp);
export const ICON_DASHBOARD = _svg(P.layoutDashboard);
// ── Card badge icons ────────────────────────────────────────
export const ICON_PALETTE = _svg(P.palette);
export const ICON_RAINBOW = _svg(P.rainbow);
export const ICON_REFRESH = _svg(P.refreshCw);
export const ICON_LINK = _svg(P.link);
export const ICON_MAP_PIN = _svg(P.mapPin);
export const ICON_MUSIC = _svg(P.music);
export const ICON_TIMER = _svg(P.timer);
export const ICON_MONITOR = _svg(P.monitor);
export const ICON_GLOBE = _svg(P.globe);
export const ICON_RADIO = _svg(P.radio);
export const ICON_PLUG = _svg(P.plug);
export const ICON_FILM = _svg(P.film);
export const ICON_FILE_TEXT = _svg(P.fileText);
export const ICON_TARGET_ICON = _svg(P.target);
export const ICON_TRENDING_UP = _svg(P.trendingUp);
export const ICON_ACTIVITY = _svg(P.activity);
export const ICON_MOVE_VERTICAL = _svg(P.moveVertical);
export const ICON_SUN_DIM = _svg(P.sunDim);
export const ICON_CAMERA = _svg(P.camera);
export const ICON_WRENCH = _svg(P.wrench);
export const ICON_SPARKLES = _svg(P.sparkles);
export const ICON_FAST_FORWARD = _svg(P.fastForward);
export const ICON_ROTATE_CW = _svg(P.rotateCw);
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
export const ICON_DOWNLOAD = _svg(P.download);
export const ICON_UNDO = _svg(P.undo2);

View File

@@ -4,6 +4,7 @@
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, confirmResolve, setConfirmResolve } from './state.js'; import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, confirmResolve, setConfirmResolve } from './state.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { ICON_PAUSE, ICON_START } from './icons.js';
export function toggleHint(btn) { export function toggleHint(btn) {
const hint = btn.closest('.label-row').nextElementSibling; const hint = btn.closest('.label-row').nextElementSibling;
@@ -125,10 +126,10 @@ export function updateAutoRefreshButton(active) {
if (!btn) return; if (!btn) return;
if (active) { if (active) {
btn.classList.add('active'); btn.classList.add('active');
btn.innerHTML = '&#x23F8;'; btn.innerHTML = ICON_PAUSE;
} else { } else {
btn.classList.remove('active'); btn.classList.remove('active');
btn.innerHTML = '&#x25B6;'; btn.innerHTML = ICON_START;
} }
} }

View File

@@ -15,6 +15,7 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { ICON_MUSIC } from '../core/icons.js';
import { loadPictureSources } from './streams.js'; import { loadPictureSources } from './streams.js';
class AudioSourceModal extends Modal { class AudioSourceModal extends Modal {
@@ -43,7 +44,7 @@ export async function showAudioSourceModal(sourceType, editData) {
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') ? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel')
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel'); : (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
document.getElementById('audio-source-modal-title').textContent = t(titleKey); document.getElementById('audio-source-modal-title').innerHTML = `${ICON_MUSIC} ${t(titleKey)}`;
document.getElementById('audio-source-id').value = isEdit ? editData.id : ''; document.getElementById('audio-source-id').value = isEdit ? editData.id : '';
document.getElementById('audio-source-error').style.display = 'none'; document.getElementById('audio-source-error').style.display = 'none';
@@ -199,7 +200,7 @@ async function _loadAudioDevices() {
const data = await resp.json(); const data = await resp.json();
const devices = data.devices || []; const devices = data.devices || [];
select.innerHTML = devices.map(d => { select.innerHTML = devices.map(d => {
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`; return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join(''); }).join('');

View File

@@ -10,6 +10,7 @@ import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
import { startCSSOverlay, stopCSSOverlay } from './color-strips.js'; import { startCSSOverlay, stopCSSOverlay } from './color-strips.js';
import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.js';
/* ── CalibrationModal subclass ────────────────────────────────── */ /* ── CalibrationModal subclass ────────────────────────────────── */
@@ -371,7 +372,7 @@ export function updateCalibrationPreview() {
const mismatch = inCSS const mismatch = inCSS
? (declaredCount > 0 && total > declaredCount) ? (declaredCount > 0 && total > declaredCount)
: (total !== declaredCount); : (total !== declaredCount);
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; document.getElementById('cal-total-leds-inline').innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total;
if (totalEl) totalEl.classList.toggle('mismatch', mismatch); if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
const startPos = document.getElementById('cal-start-position').value; const startPos = document.getElementById('cal-start-position').value;
@@ -386,7 +387,7 @@ export function updateCalibrationPreview() {
const direction = document.getElementById('cal-layout').value; const direction = document.getElementById('cal-layout').value;
const dirIcon = document.getElementById('direction-icon'); const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label'); const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺'; if (dirIcon) dirIcon.innerHTML = direction === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = document.getElementById('calibration-device-id').value;

View File

@@ -9,6 +9,9 @@ import { Modal } from '../core/modal.js';
import { import {
getColorStripIcon, getPictureSourceIcon, getColorStripIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
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_FAST_FORWARD, ICON_ACTIVITY,
} from '../core/icons.js'; } from '../core/icons.js';
class CSSEditorModal extends Modal { class CSSEditorModal extends Modal {
@@ -587,8 +590,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null; const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim const animBadge = anim
? `<span class="stream-card-prop" title="${t('color_strip.animation')}"> ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>` ? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
+ `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}"> ${(anim.speed || 1.0).toFixed(1)}×</span>` + `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`
: ''; : '';
let propsHtml; let propsHtml;
@@ -598,7 +601,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
<span class="stream-card-prop" title="${t('color_strip.static_color')}"> <span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()} <span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span> </span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge} ${animBadge}
`; `;
} else if (isColorCycle) { } else if (isColorCycle) {
@@ -608,8 +611,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join(''); ).join('');
propsHtml = ` propsHtml = `
<span class="stream-card-prop">${swatches}</span> <span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}"> ${(source.cycle_speed || 1.0).toFixed(1)}×</span> <span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
`; `;
} else if (isGradient) { } else if (isGradient) {
const stops = source.stops || []; const stops = source.stops || [];
@@ -629,31 +632,31 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
} }
propsHtml = ` propsHtml = `
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''} ${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span> <span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge} ${animBadge}
`; `;
} else if (isEffect) { } else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire'; const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : ''; const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = ` propsHtml = `
<span class="stream-card-prop"> ${escapeHtml(effectLabel)}</span> <span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">🎨 ${escapeHtml(paletteLabel)}</span>` : ''} ${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}"> ${(source.speed || 1.0).toFixed(1)}×</span> <span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
`; `;
} else if (isComposite) { } else if (isComposite) {
const layerCount = (source.layers || []).length; const layerCount = (source.layers || []).length;
const enabledCount = (source.layers || []).filter(l => l.enabled !== false).length; const enabledCount = (source.layers || []).filter(l => l.enabled !== false).length;
propsHtml = ` propsHtml = `
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span> <span class="stream-card-prop">${ICON_LINK} ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
`; `;
} else if (isMapped) { } else if (isMapped) {
const zoneCount = (source.zones || []).length; const zoneCount = (source.zones || []).length;
propsHtml = ` propsHtml = `
<span class="stream-card-prop">📍 ${zoneCount} ${t('color_strip.mapped.zones_count')}</span> <span class="stream-card-prop">${ICON_MAP_PIN} ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
`; `;
} else if (isAudio) { } else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
@@ -662,14 +665,14 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette; const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : ''; const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = ` propsHtml = `
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span> <span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">🎨 ${escapeHtml(audioPaletteLabel)}</span>` : ''} ${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span> <span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">${ICON_ACTIVITY} ${sensitivityVal}</span>
${source.audio_source_id ? (() => { ${source.audio_source_id ? (() => {
const as = audioSourceMap && audioSourceMap[source.audio_source_id]; const as = audioSourceMap && audioSourceMap[source.audio_source_id];
const asName = as ? as.name : source.audio_source_id; const asName = as ? as.name : source.audio_source_id;
const asSection = as && as.source_type === 'mono' ? 'audio-mono' : 'audio-multi'; const asSection = as && as.source_type === 'mono' ? 'audio-mono' : 'audio-multi';
return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','audio','${asSection}','data-id','${source.audio_source_id}')"` : ''}>🔊 ${escapeHtml(asName)}</span>`; return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','audio','${asSection}','data-id','${source.audio_source_id}')"` : ''}>${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}</span>`;
})() : ''} })() : ''}
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''} ${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
`; `;
@@ -680,7 +683,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}"> <span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()} <span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
</span> </span>
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">⏱️ ${timeoutVal}s</span> <span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
`; `;
} else { } else {
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id]; const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
@@ -694,8 +697,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; } else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
} }
propsHtml = ` propsHtml = `
<span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>📺 ${escapeHtml(srcName)}</span> <span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(srcName)}</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''} ${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
`; `;
} }
@@ -845,12 +848,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
} }
await _populateFromCSS(css); await _populateFromCSS(css);
document.getElementById('css-editor-title').textContent = t('color_strip.edit'); document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.edit')}`;
} else if (cloneData) { } else if (cloneData) {
document.getElementById('css-editor-id').value = ''; document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('css-editor-name').value = (cloneData.name || '') + ' (Copy)';
await _populateFromCSS(cloneData); await _populateFromCSS(cloneData);
document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
} else { } else {
document.getElementById('css-editor-id').value = ''; document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = ''; document.getElementById('css-editor-name').value = '';
@@ -887,7 +890,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-api-input-timeout').value = 5.0; document.getElementById('css-editor-api-input-timeout').value = 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0'; document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null); _showApiInputEndpoints(null);
document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
document.getElementById('css-editor-gradient-preset').value = ''; document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([ gradientInit([
{ position: 0.0, color: [255, 0, 0] }, { position: 0.0, color: [255, 0, 0] },

View File

@@ -11,7 +11,7 @@ import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
getTargetTypeIcon, getTargetTypeIcon,
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_AUTOSTART, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
} from '../core/icons.js'; } from '../core/icons.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
@@ -64,7 +64,7 @@ function _startUptimeTimer() {
if (!el) continue; if (!el) continue;
const seconds = _getInterpolatedUptime(id); const seconds = _getInterpolatedUptime(id);
if (seconds != null) { if (seconds != null) {
el.textContent = `${ICON_CLOCK} ${formatUptime(seconds)}`; el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`;
} }
} }
}, 1000); }, 1000);
@@ -218,7 +218,7 @@ function _updateRunningMetrics(enrichedRunning) {
} }
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
if (errorsEl) errorsEl.textContent = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`; if (errorsEl) errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`;
// Update health dot — prefer streaming reachability when processing // Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
@@ -267,7 +267,7 @@ function _updateProfilesInPlace(profiles) {
if (btn) { if (btn) {
btn.className = `btn btn-icon ${p.enabled ? 'btn-warning' : 'btn-success'}`; btn.className = `btn btn-icon ${p.enabled ? 'btn-warning' : 'btn-success'}`;
btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`); btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`);
btn.textContent = p.enabled ? ICON_STOP_PLAIN : '▶'; btn.innerHTML = p.enabled ? ICON_STOP_PLAIN : ICON_START;
} }
} }
} }
@@ -460,7 +460,7 @@ export async function loadDashboard(forceFullRender = false) {
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="btn btn-icon ${isRunning ? 'btn-warning' : 'btn-success'}" onclick="${isRunning ? `dashboardStopTarget('${target.id}')` : `dashboardStartTarget('${target.id}')`}" title="${isRunning ? t('device.stop') : t('device.start')}"> <button class="btn btn-icon ${isRunning ? 'btn-warning' : 'btn-success'}" onclick="${isRunning ? `dashboardStopTarget('${target.id}')` : `dashboardStartTarget('${target.id}')`}" title="${isRunning ? t('device.stop') : t('device.start')}">
${isRunning ? ICON_STOP_PLAIN : '▶'} ${isRunning ? ICON_STOP_PLAIN : ICON_START}
</button> </button>
</div> </div>
</div>`; </div>`;
@@ -518,7 +518,7 @@ export async function loadDashboard(forceFullRender = false) {
// First load: build everything in one innerHTML to avoid flicker // First load: build everything in one innerHTML to avoid flicker
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect(); const pollSelect = _renderPollIntervalSelect();
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">?</button></span></div>`; const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
if (isFirstLoad) { if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section"> container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '')} ${_sectionHeader('perf', t('dashboard.section.performance'), '')}
@@ -639,7 +639,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
<div class="dashboard-target-metrics"></div> <div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button> <button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}"></button> <button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
</div> </div>
</div>`; </div>`;
} }
@@ -690,7 +690,7 @@ function renderDashboardProfile(profile) {
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}"> <button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
${profile.enabled ? ICON_STOP_PLAIN : '▶'} ${profile.enabled ? ICON_STOP_PLAIN : ICON_START}
</button> </button>
</div> </div>
</div>`; </div>`;

View File

@@ -9,7 +9,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMock
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB } from '../core/icons.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
class DeviceSettingsModal extends Modal { class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); } constructor() { super('device-settings-modal'); }
@@ -90,7 +90,7 @@ export function createDeviceCard(device) {
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span> <span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : ''} ${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : ''}
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''} ${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span> <span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div> </div>
${(device.capabilities || []).includes('brightness_control') ? ` ${(device.capabilities || []).includes('brightness_control') ? `

View File

@@ -16,7 +16,8 @@ import { lockBody, showToast, showConfirm, formatUptime } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { import {
getValueSourceIcon, getPictureSourceIcon, getValueSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_PAUSE,
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
} from '../core/icons.js'; } from '../core/icons.js';
class KCEditorModal extends Modal { class KCEditorModal extends Modal {
@@ -126,14 +127,13 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
${escapeHtml(target.name)} ${escapeHtml(target.name)}
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div> </div>
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>📺 ${escapeHtml(sourceName)}</span> <span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','key_colors','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>📄 ${escapeHtml(patternName)}</span> <span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','key_colors','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span> <span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}"> ${kcSettings.fps ?? 10}</span> <span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''} ${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div> </div>
<div class="brightness-control" data-kc-brightness-wrap="${target.id}"> <div class="brightness-control" data-kc-brightness-wrap="${target.id}">
@@ -289,10 +289,10 @@ export function updateAutoRefreshButton(active) {
if (!btn) return; if (!btn) return;
if (active) { if (active) {
btn.classList.add('active'); btn.classList.add('active');
btn.innerHTML = '&#x23F8;'; // pause symbol btn.innerHTML = ICON_PAUSE;
} else { } else {
btn.classList.remove('active'); btn.classList.remove('active');
btn.innerHTML = '&#x25B6;'; // play symbol btn.innerHTML = ICON_START;
} }
} }
@@ -404,7 +404,7 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
const icon = getValueSourceIcon(vs.source_type); const icon = getValueSourceIcon(vs.source_type);
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = vs.id; opt.value = vs.id;
opt.textContent = `${icon} ${vs.name}`; opt.textContent = vs.name;
sel.appendChild(opt); sel.appendChild(opt);
}); });
sel.value = selectedId || ''; sel.value = selectedId || '';
@@ -462,7 +462,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || ''; patSelect.value = kcSettings.pattern_template_id || '';
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
document.getElementById('kc-editor-title').textContent = t('kc.edit'); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
} else if (cloneData) { } else if (cloneData) {
const kcSettings = cloneData.key_colors_settings || {}; const kcSettings = cloneData.key_colors_settings || {};
document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-id').value = '';
@@ -475,7 +475,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || ''; patSelect.value = kcSettings.pattern_template_id || '';
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
document.getElementById('kc-editor-title').textContent = t('kc.add'); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
} else { } else {
document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = ''; document.getElementById('kc-editor-name').value = '';
@@ -487,7 +487,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
document.getElementById('kc-editor-smoothing-value').textContent = '0.3'; document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id; if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
_populateKCBrightnessVsDropdown(''); _populateKCBrightnessVsDropdown('');
document.getElementById('kc-editor-title').textContent = t('kc.add'); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
} }
// Auto-name // Auto-name

View File

@@ -97,19 +97,19 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
document.getElementById('pattern-template-id').value = tmpl.id; document.getElementById('pattern-template-id').value = tmpl.id;
document.getElementById('pattern-template-name').value = tmpl.name; document.getElementById('pattern-template-name').value = tmpl.name;
document.getElementById('pattern-template-description').value = tmpl.description || ''; document.getElementById('pattern-template-description').value = tmpl.description || '';
document.getElementById('pattern-template-modal-title').textContent = t('pattern.edit'); document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
} else if (cloneData) { } else if (cloneData) {
document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pattern-template-description').value = cloneData.description || ''; document.getElementById('pattern-template-description').value = cloneData.description || '';
document.getElementById('pattern-template-modal-title').textContent = t('pattern.add'); document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r }))); setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
} else { } else {
document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = ''; document.getElementById('pattern-template-name').value = '';
document.getElementById('pattern-template-description').value = ''; document.getElementById('pattern-template-description').value = '';
document.getElementById('pattern-template-modal-title').textContent = t('pattern.add'); document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects([]); setPatternEditorRects([]);
} }

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js'; import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js';
class ProfileEditorModal extends Modal { class ProfileEditorModal extends Modal {
constructor() { super('profile-editor-modal'); } constructor() { super('profile-editor-modal'); }
@@ -88,7 +88,7 @@ function renderProfiles(profiles, runningTargetIds = new Set()) {
const container = document.getElementById('profiles-content'); const container = document.getElementById('profiles-content');
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }))); const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">?</button></span></div>`; const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csProfiles.render(items); container.innerHTML = toolbar + csProfiles.render(items);
csProfiles.bind(); csProfiles.bind();
@@ -109,7 +109,7 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
} else { } else {
const parts = profile.conditions.map(c => { const parts = profile.conditions.map(c => {
if (c.condition_type === 'always') { if (c.condition_type === 'always') {
return `<span class="stream-card-prop"> ${t('profiles.condition.always')}</span>`; return `<span class="stream-card-prop">${ICON_OK} ${t('profiles.condition.always')}</span>`;
} }
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ');
@@ -127,7 +127,7 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
let lastActivityMeta = ''; let lastActivityMeta = '';
if (profile.last_activated_at) { if (profile.last_activated_at) {
const ts = new Date(profile.last_activated_at); const ts = new Date(profile.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">🕐 ${ts.toLocaleString()}</span>`; lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
} }
return ` return `
@@ -143,7 +143,7 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
</div> </div>
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span> <span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span>
<span class="card-meta"> ${targetCountText}</span> <span class="card-meta">${ICON_TARGET} ${targetCountText}</span>
${lastActivityMeta} ${lastActivityMeta}
</div> </div>
<div class="stream-card-props">${condPills}</div> <div class="stream-card-props">${condPills}</div>
@@ -158,7 +158,7 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
</button>`; </button>`;
})() : ''} })() : ''}
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}"> <button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
${profile.enabled ? ICON_PAUSE : '▶'} ${profile.enabled ? ICON_PAUSE : ICON_START}
</button> </button>
</div> </div>
</div>`; </div>`;
@@ -180,7 +180,7 @@ export async function openProfileEditor(profileId) {
await loadProfileTargetChecklist([]); await loadProfileTargetChecklist([]);
if (profileId) { if (profileId) {
titleEl.textContent = t('profiles.edit'); titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`;
try { try {
const resp = await fetchWithAuth(`/profiles/${profileId}`); const resp = await fetchWithAuth(`/profiles/${profileId}`);
if (!resp.ok) throw new Error('Failed to load profile'); if (!resp.ok) throw new Error('Failed to load profile');
@@ -201,7 +201,7 @@ export async function openProfileEditor(profileId) {
return; return;
} }
} else { } else {
titleEl.textContent = t('profiles.add'); titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.add')}`;
idInput.value = ''; idInput.value = '';
nameInput.value = ''; nameInput.value = '';
enabledInput.checked = true; enabledInput.checked = true;

View File

@@ -7,6 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
// Simple modal (no form / no dirty check needed) // Simple modal (no form / no dirty check needed)
const settingsModal = new Modal('settings-modal'); const settingsModal = new Modal('settings-modal');
@@ -207,8 +208,8 @@ export async function loadBackupList() {
<span>${date}</span> <span>${date}</span>
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span> <span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
</div> </div>
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">&#x21BA;</button> <button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button>
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">&#x2B07;</button> <button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">&#x2715;</button>
</div>`; </div>`;
}).join(''); }).join('');

View File

@@ -39,7 +39,8 @@ import {
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js'; } from '../core/icons.js';
// ── Card section instances ── // ── Card section instances ──
@@ -166,7 +167,7 @@ async function loadCaptureTemplates() {
export async function showAddTemplateModal(cloneData = null) { export async function showAddTemplateModal(cloneData = null) {
setCurrentEditingTemplateId(null); setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title').textContent = t('templates.add'); document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
document.getElementById('template-form').reset(); document.getElementById('template-form').reset();
document.getElementById('template-id').value = ''; document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none'; document.getElementById('engine-config-section').style.display = 'none';
@@ -197,7 +198,7 @@ export async function editTemplate(templateId) {
const template = await response.json(); const template = await response.json();
setCurrentEditingTemplateId(templateId); setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title').textContent = t('templates.edit'); document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
document.getElementById('template-id').value = templateId; document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name; document.getElementById('template-name').value = template.name;
document.getElementById('template-description').value = template.description || ''; document.getElementById('template-description').value = template.description || '';
@@ -751,7 +752,7 @@ async function loadAudioTemplates() {
export async function showAddAudioTemplateModal(cloneData = null) { export async function showAddAudioTemplateModal(cloneData = null) {
setCurrentEditingAudioTemplateId(null); setCurrentEditingAudioTemplateId(null);
document.getElementById('audio-template-modal-title').textContent = t('audio_template.add'); document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
document.getElementById('audio-template-form').reset(); document.getElementById('audio-template-form').reset();
document.getElementById('audio-template-id').value = ''; document.getElementById('audio-template-id').value = '';
document.getElementById('audio-engine-config-section').style.display = 'none'; document.getElementById('audio-engine-config-section').style.display = 'none';
@@ -781,7 +782,7 @@ export async function editAudioTemplate(templateId) {
const template = await response.json(); const template = await response.json();
setCurrentEditingAudioTemplateId(templateId); setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title').textContent = t('audio_template.edit'); document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
document.getElementById('audio-template-id').value = templateId; document.getElementById('audio-template-id').value = templateId;
document.getElementById('audio-template-name').value = template.name; document.getElementById('audio-template-name').value = template.name;
document.getElementById('audio-template-description').value = template.description || ''; document.getElementById('audio-template-description').value = template.description || '';
@@ -900,7 +901,7 @@ export async function showTestAudioTemplateModal(templateId) {
const data = await resp.json(); const data = await resp.json();
const devices = data.devices || []; const devices = data.devices || [];
deviceSelect.innerHTML = devices.map(d => { deviceSelect.innerHTML = devices.map(d => {
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`; return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join(''); }).join('');
@@ -1182,7 +1183,7 @@ function renderPictureSourcesList(streams) {
if (capTmpl) capTmplName = escapeHtml(capTmpl.name); if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
} }
detailsHtml = `<div class="stream-card-props"> detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span> <span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span> <span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-templates','data-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''} ${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-templates','data-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
</div>`; </div>`;
@@ -1236,7 +1237,7 @@ function renderPictureSourcesList(streams) {
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''} ${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span> <span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''} ${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div> </div>
${configEntries.length > 0 ? ` ${configEntries.length > 0 ? `
<details class="template-config-details"> <details class="template-config-details">
@@ -1301,7 +1302,7 @@ function renderPictureSourcesList(streams) {
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab => const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>` `<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" title="${t('tour.restart')}">?</button></span></div>`; ).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
const renderAudioSourceCard = (src) => { const renderAudioSourceCard = (src) => {
const isMono = src.source_type === 'mono'; const isMono = src.source_type === 'mono';
@@ -1317,7 +1318,7 @@ function renderPictureSourcesList(streams) {
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`; : `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
propsHtml = ` propsHtml = `
${parentBadge} ${parentBadge}
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">📻 ${chLabel}</span> <span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
`; `;
} else { } else {
const devIdx = src.device_index ?? -1; const devIdx = src.device_index ?? -1;
@@ -1356,7 +1357,7 @@ function renderPictureSourcesList(streams) {
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''} ${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span> <span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">🔧 ${configEntries.length}</span>` : ''} ${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div> </div>
${configEntries.length > 0 ? ` ${configEntries.length > 0 ? `
<details class="template-config-details"> <details class="template-config-details">
@@ -1456,7 +1457,7 @@ function _autoGenerateStreamName() {
export async function showAddStreamModal(presetType, cloneData = null) { export async function showAddStreamModal(presetType, cloneData = null) {
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw'; const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add'); document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(streamType)} ${t(titleKeys[streamType] || 'streams.add')}`;
document.getElementById('stream-form').reset(); document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = ''; document.getElementById('stream-id').value = '';
document.getElementById('stream-display-index').value = ''; document.getElementById('stream-display-index').value = '';
@@ -1513,7 +1514,7 @@ export async function editStream(streamId) {
const stream = await response.json(); const stream = await response.json();
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' }; const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit'); document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(stream.stream_type)} ${t(editTitleKeys[stream.stream_type] || 'streams.edit')}`;
document.getElementById('stream-id').value = streamId; document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name; document.getElementById('stream-name').value = stream.name;
document.getElementById('stream-description').value = stream.description || ''; document.getElementById('stream-description').value = stream.description || '';
@@ -2108,7 +2109,7 @@ function _autoGeneratePPTemplateName() {
export async function showAddPPTemplateModal(cloneData = null) { export async function showAddPPTemplateModal(cloneData = null) {
if (_availableFilters.length === 0) await loadAvailableFilters(); if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); document.getElementById('pp-template-modal-title').innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.add')}`;
document.getElementById('pp-template-form').reset(); document.getElementById('pp-template-form').reset();
document.getElementById('pp-template-id').value = ''; document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none'; document.getElementById('pp-template-error').style.display = 'none';
@@ -2146,7 +2147,7 @@ export async function editPPTemplate(templateId) {
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json(); const tmpl = await response.json();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit'); document.getElementById('pp-template-modal-title').innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.edit')}`;
document.getElementById('pp-template-id').value = templateId; document.getElementById('pp-template-id').value = templateId;
document.getElementById('pp-template-name').value = tmpl.name; document.getElementById('pp-template-name').value = tmpl.name;
document.getElementById('pp-template-description').value = tmpl.description || ''; document.getElementById('pp-template-description').value = tmpl.description || '';

View File

@@ -21,6 +21,7 @@ import {
getValueSourceIcon, getTargetTypeIcon, getValueSourceIcon, getTargetTypeIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
} from '../core/icons.js'; } from '../core/icons.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -276,7 +277,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
const thresh = target.min_brightness_threshold ?? 0; const thresh = target.min_brightness_threshold ?? 0;
document.getElementById('target-editor-brightness-threshold').value = thresh; document.getElementById('target-editor-brightness-threshold').value = thresh;
@@ -297,7 +298,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
const cloneThresh = cloneData.min_brightness_threshold ?? 0; const cloneThresh = cloneData.min_brightness_threshold ?? 0;
document.getElementById('target-editor-brightness-threshold').value = cloneThresh; document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
@@ -316,7 +317,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-keepalive-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
document.getElementById('target-editor-brightness-threshold').value = 0; document.getElementById('target-editor-brightness-threshold').value = 0;
document.getElementById('target-editor-brightness-threshold-value').textContent = '0'; document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
@@ -576,7 +577,7 @@ export async function loadTargetsTab() {
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab => const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>` `<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" title="${t('tour.restart')}">?</button></span></div>`; ).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
// Use window.createPatternTemplateCard to avoid circular import // Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
@@ -867,16 +868,15 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<div class="card-title"> <div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)} ${escapeHtml(target.name)}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div> </div>
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span> <span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span> <span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
${device?.device_type === 'wled' || !device ? `<span class="stream-card-prop" title="${t('targets.protocol')}">${target.protocol === 'http' ? '🌐' : '📡'} ${(target.protocol || 'ddp').toUpperCase()}</span>` : `<span class="stream-card-prop" title="${t('targets.protocol')}">🔌 ${t('targets.protocol.serial')}</span>`} ${device?.device_type === 'wled' || !device ? `<span class="stream-card-prop" title="${t('targets.protocol')}">${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${(target.protocol || 'ddp').toUpperCase()}</span>` : `<span class="stream-card-prop" title="${t('targets.protocol')}">${ICON_PLUG} ${t('targets.protocol.serial')}</span>`}
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>🎞️ ${cssSummary}</span> <span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''} ${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">🔅 &lt;${target.min_brightness_threshold} → off</span>` : ''} ${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''}
</div> </div>
<div class="card-content"> <div class="card-content">
${isProcessing ? ` ${isProcessing ? `
@@ -1123,7 +1123,7 @@ function connectLedPreviewWS(targetId) {
if (bLabel) { if (bLabel) {
const pct = Math.round(brightness / 255 * 100); const pct = Math.round(brightness / 255 * 100);
if (pct < 100 || bLabel.dataset.hasBvs) { if (pct < 100 || bLabel.dataset.hasBvs) {
bLabel.textContent = ` ${pct}%`; bLabel.innerHTML = `${ICON_SUN_DIM} ${pct}%`;
bLabel.style.display = ''; bLabel.style.display = '';
} else { } else {
bLabel.style.display = 'none'; bLabel.style.display = 'none';

View File

@@ -15,7 +15,12 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.js'; import {
getValueSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js';
import { loadPictureSources } from './streams.js'; import { loadPictureSources } from './streams.js';
export { getValueSourceIcon }; export { getValueSourceIcon };
@@ -58,7 +63,8 @@ export async function showValueSourceModal(editData) {
const isEdit = !!editData; const isEdit = !!editData;
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add'; const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
document.getElementById('value-source-modal-title').textContent = t(titleKey); const titleIcon = isEdit ? getValueSourceIcon(editData.source_type) : getValueSourceIcon('static');
document.getElementById('value-source-modal-title').innerHTML = `${titleIcon} ${t(titleKey)}`;
document.getElementById('value-source-id').value = isEdit ? editData.id : ''; document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none'; document.getElementById('value-source-error').style.display = 'none';
@@ -471,13 +477,13 @@ export function createValueSourceCard(src) {
let propsHtml = ''; let propsHtml = '';
if (src.source_type === 'static') { if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">📊 ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`; propsHtml = `<span class="stream-card-prop">${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
} else if (src.source_type === 'animated') { } else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine'; const waveLabel = src.waveform || 'sine';
propsHtml = ` propsHtml = `
<span class="stream-card-prop">〰️ ${escapeHtml(waveLabel)}</span> <span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">⏱️ ${src.speed ?? 10} cpm</span> <span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span> <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`; `;
} else if (src.source_type === 'audio') { } else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id); const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
@@ -485,18 +491,18 @@ export function createValueSourceCard(src) {
const audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : 'audio-multi') : 'audio-multi'; const audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : 'audio-multi') : 'audio-multi';
const modeLabel = src.mode || 'rms'; const modeLabel = src.mode || 'rms';
const audioBadge = audioSrc const audioBadge = audioSrc
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','${audioSection}','data-id','${src.audio_source_id}')">🎵 ${escapeHtml(audioName)}</span>` ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">🎵 ${escapeHtml(audioName)}</span>`; : `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`;
propsHtml = ` propsHtml = `
${audioBadge} ${audioBadge}
<span class="stream-card-prop">📈 ${modeLabel.toUpperCase()}</span> <span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span> <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`; `;
} else if (src.source_type === 'adaptive_time') { } else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length; const pts = (src.schedule || []).length;
propsHtml = ` propsHtml = `
<span class="stream-card-prop">📍 ${pts} ${t('value_source.schedule.points')}</span> <span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span> <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`; `;
} else if (src.source_type === 'adaptive_scene') { } else if (src.source_type === 'adaptive_scene') {
const ps = _cachedStreams.find(s => s.id === src.picture_source_id); const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
@@ -507,11 +513,11 @@ export function createValueSourceCard(src) {
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; } else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
} }
const psBadge = ps const psBadge = ps
? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">🖥️ ${escapeHtml(psName)}</span>` ? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">${ICON_MONITOR} ${escapeHtml(psName)}</span>`
: `<span class="stream-card-prop">🖥️ ${escapeHtml(psName)}</span>`; : `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`;
propsHtml = ` propsHtml = `
${psBadge} ${psBadge}
<span class="stream-card-prop">🔄 ${src.scene_behavior || 'complement'}</span> <span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`; `;
} }

View File

@@ -3,6 +3,8 @@
"app.version": "Version:", "app.version": "Version:",
"app.api_docs": "API Documentation", "app.api_docs": "API Documentation",
"theme.toggle": "Toggle theme", "theme.toggle": "Toggle theme",
"accent.title": "Accent color",
"accent.custom": "Custom",
"locale.change": "Change language", "locale.change": "Change language",
"auth.login": "Login", "auth.login": "Login",
"auth.logout": "Logout", "auth.logout": "Logout",
@@ -21,7 +23,7 @@
"auth.please_login": "Please login to view", "auth.please_login": "Please login to view",
"auth.session_expired": "Your session has expired or the API key is invalid. Please login again.", "auth.session_expired": "Your session has expired or the API key is invalid. Please login again.",
"displays.title": "Available Displays", "displays.title": "Available Displays",
"displays.layout": "\uD83D\uDDA5\uFE0F Displays", "displays.layout": "Displays",
"displays.information": "Display Information", "displays.information": "Display Information",
"displays.legend.primary": "Primary Display", "displays.legend.primary": "Primary Display",
"displays.legend.secondary": "Secondary Display", "displays.legend.secondary": "Secondary Display",
@@ -44,7 +46,7 @@
"displays.picker.adb_connect.error": "Failed to connect device", "displays.picker.adb_connect.error": "Failed to connect device",
"displays.picker.adb_disconnect": "Disconnect", "displays.picker.adb_disconnect": "Disconnect",
"displays.picker.no_android": "No Android devices found. Connect via USB or enter IP above.", "displays.picker.no_android": "No Android devices found. Connect via USB or enter IP above.",
"templates.title": "\uD83D\uDCC4 Engine Templates", "templates.title": "Engine Templates",
"templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.", "templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.",
"templates.loading": "Loading templates...", "templates.loading": "Loading templates...",
"templates.empty": "No capture templates configured", "templates.empty": "No capture templates configured",
@@ -77,7 +79,7 @@
"templates.test.display.select": "Select display...", "templates.test.display.select": "Select display...",
"templates.test.duration": "Capture Duration (s):", "templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):", "templates.test.border_width": "Border Width (px):",
"templates.test.run": "\uD83E\uDDEA Run", "templates.test.run": "Run",
"templates.test.running": "Running test...", "templates.test.running": "Running test...",
"templates.test.results.preview": "Full Capture Preview", "templates.test.results.preview": "Full Capture Preview",
"templates.test.results.borders": "Border Extraction", "templates.test.results.borders": "Border Extraction",
@@ -97,7 +99,7 @@
"templates.test.error.no_engine": "Please select a capture engine", "templates.test.error.no_engine": "Please select a capture engine",
"templates.test.error.no_display": "Please select a display", "templates.test.error.no_display": "Please select a display",
"templates.test.error.failed": "Test failed", "templates.test.error.failed": "Test failed",
"devices.title": "\uD83D\uDCA1 Devices", "devices.title": "Devices",
"devices.add": "Add New Device", "devices.add": "Add New Device",
"devices.loading": "Loading devices...", "devices.loading": "Loading devices...",
"devices.none": "No devices configured", "devices.none": "No devices configured",
@@ -180,7 +182,7 @@
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage", "device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open the device's built-in web interface for advanced configuration", "device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new LED device", "device.tip.add": "Click here to add a new LED device",
"settings.title": "Device Settings", "settings.title": "Settings",
"settings.general.title": "General Settings", "settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings", "settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
@@ -285,12 +287,12 @@
"section.filter.reset": "Clear filter", "section.filter.reset": "Clear filter",
"section.expand_all": "Expand all sections", "section.expand_all": "Expand all sections",
"section.collapse_all": "Collapse all sections", "section.collapse_all": "Collapse all sections",
"streams.title": "\uD83D\uDCFA Sources", "streams.title": "Sources",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Screen Capture", "streams.group.raw": "Screen Capture",
"streams.group.processed": "Processed", "streams.group.processed": "Processed",
"streams.group.audio": "Audio", "streams.group.audio": "Audio",
"streams.section.streams": "\uD83D\uDCFA Sources", "streams.section.streams": "Sources",
"streams.add": "Add Source", "streams.add": "Add Source",
"streams.add.raw": "Add Screen Capture", "streams.add.raw": "Add Screen Capture",
"streams.add.processed": "Add Processed Source", "streams.add.processed": "Add Processed Source",
@@ -322,11 +324,11 @@
"streams.error.required": "Please fill in all required fields", "streams.error.required": "Please fill in all required fields",
"streams.error.delete": "Failed to delete source", "streams.error.delete": "Failed to delete source",
"streams.test.title": "Test Source", "streams.test.title": "Test Source",
"streams.test.run": "🧪 Run", "streams.test.run": "Run",
"streams.test.running": "Testing source...", "streams.test.running": "Testing source...",
"streams.test.duration": "Capture Duration (s):", "streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Source test failed", "streams.test.error.failed": "Source test failed",
"postprocessing.title": "\uD83D\uDCC4 Filter Templates", "postprocessing.title": "Filter Templates",
"postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture sources for consistent postprocessing across devices.", "postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture sources for consistent postprocessing across devices.",
"postprocessing.add": "Add Filter Template", "postprocessing.add": "Add Filter Template",
"postprocessing.edit": "Edit Filter Template", "postprocessing.edit": "Edit Filter Template",
@@ -363,7 +365,7 @@
"postprocessing.test.error.no_stream": "Please select a source", "postprocessing.test.error.no_stream": "Please select a source",
"postprocessing.test.error.failed": "Processing template test failed", "postprocessing.test.error.failed": "Processing template test failed",
"device.button.stream_selector": "Source Settings", "device.button.stream_selector": "Source Settings",
"device.stream_settings.title": "📺 Source Settings", "device.stream_settings.title": "Source Settings",
"device.stream_selector.label": "Source:", "device.stream_selector.label": "Source:",
"device.stream_selector.hint": "Select a source that defines what this device captures and processes", "device.stream_selector.hint": "Select a source that defines what this device captures and processes",
"device.stream_selector.none": "-- No source assigned --", "device.stream_selector.none": "-- No source assigned --",
@@ -388,13 +390,13 @@
"streams.validate_image.validating": "Validating...", "streams.validate_image.validating": "Validating...",
"streams.validate_image.valid": "Image accessible", "streams.validate_image.valid": "Image accessible",
"streams.validate_image.invalid": "Image not accessible", "streams.validate_image.invalid": "Image not accessible",
"targets.title": "Targets", "targets.title": "Targets",
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.", "targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
"targets.subtab.led": "LED", "targets.subtab.led": "LED",
"targets.section.devices": "💡 Devices", "targets.section.devices": "Devices",
"targets.section.color_strips": "🎞️ Color Strip Sources", "targets.section.color_strips": "Color Strip Sources",
"targets.section.targets": "Targets", "targets.section.targets": "Targets",
"targets.section.specific_settings": "Specific Settings", "targets.section.specific_settings": "Specific Settings",
"targets.add": "Add Target", "targets.add": "Add Target",
"targets.edit": "Edit Target", "targets.edit": "Edit Target",
@@ -444,7 +446,7 @@
"targets.metrics.frames": "Frames", "targets.metrics.frames": "Frames",
"targets.metrics.errors": "Errors", "targets.metrics.errors": "Errors",
"targets.subtab.key_colors": "Key Colors", "targets.subtab.key_colors": "Key Colors",
"targets.section.key_colors": "🎨 Key Colors Targets", "targets.section.key_colors": "Key Colors Targets",
"kc.add": "Add Key Colors Target", "kc.add": "Add Key Colors Target",
"kc.edit": "Edit Key Colors Target", "kc.edit": "Edit Key Colors Target",
"kc.name": "Target Name:", "kc.name": "Target Name:",
@@ -476,9 +478,9 @@
"kc.colors.none": "No colors extracted yet", "kc.colors.none": "No colors extracted yet",
"kc.test": "Test", "kc.test": "Test",
"kc.test.error": "Test failed", "kc.test.error": "Test failed",
"targets.section.pattern_templates": "📄 Pattern Templates", "targets.section.pattern_templates": "Pattern Templates",
"pattern.add": "📄 Add Pattern Template", "pattern.add": "Add Pattern Template",
"pattern.edit": "📄 Edit Pattern Template", "pattern.edit": "Edit Pattern Template",
"pattern.name": "Template Name:", "pattern.name": "Template Name:",
"pattern.name.placeholder": "My Pattern Template", "pattern.name.placeholder": "My Pattern Template",
"pattern.description_label": "Description (optional):", "pattern.description_label": "Description (optional):",
@@ -513,7 +515,7 @@
"overlay.stopped": "Overlay visualization stopped", "overlay.stopped": "Overlay visualization stopped",
"overlay.error.start": "Failed to start overlay", "overlay.error.start": "Failed to start overlay",
"overlay.error.stop": "Failed to stop overlay", "overlay.error.stop": "Failed to stop overlay",
"dashboard.title": "📊 Dashboard", "dashboard.title": "Dashboard",
"dashboard.section.targets": "Targets", "dashboard.section.targets": "Targets",
"dashboard.section.running": "Running", "dashboard.section.running": "Running",
"dashboard.section.stopped": "Stopped", "dashboard.section.stopped": "Stopped",
@@ -532,10 +534,9 @@
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "unavailable", "dashboard.perf.unavailable": "unavailable",
"dashboard.poll_interval": "Refresh interval", "dashboard.poll_interval": "Refresh interval",
"profiles.title": "Profiles",
"profiles.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.", "profiles.empty": "No profiles configured. Create one to automate target activation.",
"profiles.add": "\uD83D\uDCCB Add Profile", "profiles.add": "Add Profile",
"profiles.edit": "Edit Profile", "profiles.edit": "Edit Profile",
"profiles.delete.confirm": "Delete profile \"{name}\"?", "profiles.delete.confirm": "Delete profile \"{name}\"?",
"profiles.name": "Name:", "profiles.name": "Name:",
@@ -549,7 +550,7 @@
"profiles.conditions": "Conditions:", "profiles.conditions": "Conditions:",
"profiles.conditions.hint": "Rules that determine when this profile activates", "profiles.conditions.hint": "Rules that determine when this profile activates",
"profiles.conditions.add": "Add Condition", "profiles.conditions.add": "Add Condition",
"profiles.conditions.empty": "No conditions \u2014 profile is always active when enabled", "profiles.conditions.empty": "No conditions profile is always active when enabled",
"profiles.condition.always": "Always", "profiles.condition.always": "Always",
"profiles.condition.always.hint": "Profile activates immediately when enabled and stays active. Use this to auto-start targets on server startup.", "profiles.condition.always.hint": "Profile activates immediately when enabled and stays active. Use this to auto-start targets on server startup.",
"profiles.condition.application": "Application", "profiles.condition.application": "Application",
@@ -597,9 +598,8 @@
"aria.previous": "Previous", "aria.previous": "Previous",
"aria.next": "Next", "aria.next": "Next",
"aria.hint": "Show hint", "aria.hint": "Show hint",
"color_strip.add": "Add Color Strip Source",
"color_strip.add": "🎞️ Add Color Strip Source", "color_strip.edit": "Edit Color Strip Source",
"color_strip.edit": "🎞️ Edit Color Strip Source",
"color_strip.name": "Name:", "color_strip.name": "Name:",
"color_strip.name.placeholder": "Wall Strip", "color_strip.name.placeholder": "Wall Strip",
"color_strip.picture_source": "Picture Source:", "color_strip.picture_source": "Picture Source:",
@@ -786,7 +786,6 @@
"color_strip.palette.aurora": "Aurora", "color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset", "color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice", "color_strip.palette.ice": "Ice",
"audio_source.title": "Audio Sources", "audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel", "audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono", "audio_source.group.mono": "Mono",
@@ -829,14 +828,12 @@
"audio_source.test.beat": "Beat", "audio_source.test.beat": "Beat",
"audio_source.test.connecting": "Connecting...", "audio_source.test.connecting": "Connecting...",
"audio_source.test.error": "Audio test failed", "audio_source.test.error": "Audio test failed",
"audio_template.test": "Test", "audio_template.test": "Test",
"audio_template.test.title": "Test Audio Template", "audio_template.test.title": "Test Audio Template",
"audio_template.test.device": "Audio Device:", "audio_template.test.device": "Audio Device:",
"audio_template.test.device.hint": "Select which audio device to capture from during the test", "audio_template.test.device.hint": "Select which audio device to capture from during the test",
"audio_template.test.run": "🧪 Run", "audio_template.test.run": "Run",
"audio_template.title": "Audio Templates",
"audio_template.title": "🎵 Audio Templates",
"audio_template.add": "Add Audio Template", "audio_template.add": "Add Audio Template",
"audio_template.edit": "Edit Audio Template", "audio_template.edit": "Edit Audio Template",
"audio_template.name": "Template Name:", "audio_template.name": "Template Name:",
@@ -857,9 +854,8 @@
"audio_template.error.engines": "Failed to load audio engines", "audio_template.error.engines": "Failed to load audio engines",
"audio_template.error.required": "Please fill in all required fields", "audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template", "audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources", "streams.group.value": "Value Sources",
"value_source.group.title": "🔢 Value Sources", "value_source.group.title": "Value Sources",
"value_source.add": "Add Value Source", "value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source", "value_source.edit": "Edit Value Source",
"value_source.name": "Name:", "value_source.name": "Name:",
@@ -947,7 +943,6 @@
"targets.protocol": "Protocol:", "targets.protocol": "Protocol:",
"targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.", "targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.",
"targets.protocol.serial": "Serial", "targets.protocol.serial": "Serial",
"search.open": "Search (Ctrl+K)", "search.open": "Search (Ctrl+K)",
"search.placeholder": "Search entities... (Ctrl+K)", "search.placeholder": "Search entities... (Ctrl+K)",
"search.loading": "Loading...", "search.loading": "Loading...",
@@ -963,8 +958,6 @@
"search.group.pattern_templates": "Pattern Templates", "search.group.pattern_templates": "Pattern Templates",
"search.group.audio": "Audio Sources", "search.group.audio": "Audio Sources",
"search.group.value": "Value Sources", "search.group.value": "Value Sources",
"settings.title": "Settings",
"settings.backup.label": "Backup Configuration", "settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
"settings.backup.button": "Download Backup", "settings.backup.button": "Download Backup",
@@ -979,7 +972,6 @@
"settings.restore.restarting": "Server is restarting...", "settings.restore.restarting": "Server is restarting...",
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.", "settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
"settings.button.close": "Close", "settings.button.close": "Close",
"settings.auto_backup.label": "Auto-Backup", "settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup", "settings.auto_backup.enable": "Enable auto-backup",
@@ -990,7 +982,6 @@
"settings.auto_backup.save_error": "Failed to save auto-backup settings", "settings.auto_backup.save_error": "Failed to save auto-backup settings",
"settings.auto_backup.last_backup": "Last backup", "settings.auto_backup.last_backup": "Last backup",
"settings.auto_backup.never": "Never", "settings.auto_backup.never": "Never",
"settings.saved_backups.label": "Saved Backups", "settings.saved_backups.label": "Saved Backups",
"settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.", "settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.",
"settings.saved_backups.empty": "No saved backups", "settings.saved_backups.empty": "No saved backups",

View File

@@ -3,6 +3,8 @@
"app.version": "Версия:", "app.version": "Версия:",
"app.api_docs": "Документация API", "app.api_docs": "Документация API",
"theme.toggle": "Переключить тему", "theme.toggle": "Переключить тему",
"accent.title": "Цвет акцента",
"accent.custom": "Свой",
"locale.change": "Изменить язык", "locale.change": "Изменить язык",
"auth.login": "Войти", "auth.login": "Войти",
"auth.logout": "Выйти", "auth.logout": "Выйти",
@@ -21,7 +23,7 @@
"auth.please_login": "Пожалуйста, войдите для просмотра", "auth.please_login": "Пожалуйста, войдите для просмотра",
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.", "auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
"displays.title": "Доступные Дисплеи", "displays.title": "Доступные Дисплеи",
"displays.layout": "\uD83D\uDDA5\uFE0F Дисплеи", "displays.layout": "Дисплеи",
"displays.information": "Информация о Дисплеях", "displays.information": "Информация о Дисплеях",
"displays.legend.primary": "Основной Дисплей", "displays.legend.primary": "Основной Дисплей",
"displays.legend.secondary": "Вторичный Дисплей", "displays.legend.secondary": "Вторичный Дисплей",
@@ -44,7 +46,7 @@
"displays.picker.adb_connect.error": "Не удалось подключить устройство", "displays.picker.adb_connect.error": "Не удалось подключить устройство",
"displays.picker.adb_disconnect": "Отключить", "displays.picker.adb_disconnect": "Отключить",
"displays.picker.no_android": "Android устройства не найдены. Подключите по USB или введите IP выше.", "displays.picker.no_android": "Android устройства не найдены. Подключите по USB или введите IP выше.",
"templates.title": "\uD83D\uDCC4 Шаблоны Движков", "templates.title": "Шаблоны Движков",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.", "templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...", "templates.loading": "Загрузка шаблонов...",
"templates.empty": "Шаблоны захвата не настроены", "templates.empty": "Шаблоны захвата не настроены",
@@ -77,7 +79,7 @@
"templates.test.display.select": "Выберите дисплей...", "templates.test.display.select": "Выберите дисплей...",
"templates.test.duration": "Длительность Захвата (с):", "templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):", "templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "\uD83E\uDDEA Запустить", "templates.test.run": "Запустить",
"templates.test.running": "Выполняется тест...", "templates.test.running": "Выполняется тест...",
"templates.test.results.preview": "Полный Предпросмотр Захвата", "templates.test.results.preview": "Полный Предпросмотр Захвата",
"templates.test.results.borders": "Извлечение Границ", "templates.test.results.borders": "Извлечение Границ",
@@ -97,7 +99,7 @@
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата", "templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
"templates.test.error.no_display": "Пожалуйста, выберите дисплей", "templates.test.error.no_display": "Пожалуйста, выберите дисплей",
"templates.test.error.failed": "Тест не удался", "templates.test.error.failed": "Тест не удался",
"devices.title": "\uD83D\uDCA1 Устройства", "devices.title": "Устройства",
"devices.add": "Добавить Новое Устройство", "devices.add": "Добавить Новое Устройство",
"devices.loading": "Загрузка устройств...", "devices.loading": "Загрузка устройств...",
"devices.none": "Устройства не настроены", "devices.none": "Устройства не настроены",
@@ -180,7 +182,7 @@
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия", "device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки", "device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки Устройства", "settings.title": "Настройки",
"settings.general.title": "Основные Настройки", "settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата", "settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
@@ -285,12 +287,12 @@
"section.filter.reset": "Очистить фильтр", "section.filter.reset": "Очистить фильтр",
"section.expand_all": "Развернуть все секции", "section.expand_all": "Развернуть все секции",
"section.collapse_all": "Свернуть все секции", "section.collapse_all": "Свернуть все секции",
"streams.title": "\uD83D\uDCFA Источники", "streams.title": "Источники",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана", "streams.group.raw": "Захват Экрана",
"streams.group.processed": "Обработанные", "streams.group.processed": "Обработанные",
"streams.group.audio": "Аудио", "streams.group.audio": "Аудио",
"streams.section.streams": "\uD83D\uDCFA Источники", "streams.section.streams": "Источники",
"streams.add": "Добавить Источник", "streams.add": "Добавить Источник",
"streams.add.raw": "Добавить Захват Экрана", "streams.add.raw": "Добавить Захват Экрана",
"streams.add.processed": "Добавить Обработанный", "streams.add.processed": "Добавить Обработанный",
@@ -322,11 +324,11 @@
"streams.error.required": "Пожалуйста, заполните все обязательные поля", "streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить источник", "streams.error.delete": "Не удалось удалить источник",
"streams.test.title": "Тест Источника", "streams.test.title": "Тест Источника",
"streams.test.run": "🧪 Запустить", "streams.test.run": "Запустить",
"streams.test.running": "Тестирование источника...", "streams.test.running": "Тестирование источника...",
"streams.test.duration": "Длительность Захвата (с):", "streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест источника не удался", "streams.test.error.failed": "Тест источника не удался",
"postprocessing.title": "\uD83D\uDCC4 Шаблоны Фильтров", "postprocessing.title": "Шаблоны Фильтров",
"postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным источникам для единообразной постобработки на всех устройствах.", "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным источникам для единообразной постобработки на всех устройствах.",
"postprocessing.add": "Добавить Шаблон Фильтра", "postprocessing.add": "Добавить Шаблон Фильтра",
"postprocessing.edit": "Редактировать Шаблон Фильтра", "postprocessing.edit": "Редактировать Шаблон Фильтра",
@@ -363,7 +365,7 @@
"postprocessing.test.error.no_stream": "Пожалуйста, выберите источник", "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник",
"postprocessing.test.error.failed": "Тест шаблона фильтра не удался", "postprocessing.test.error.failed": "Тест шаблона фильтра не удался",
"device.button.stream_selector": "Настройки источника", "device.button.stream_selector": "Настройки источника",
"device.stream_settings.title": "📺 Настройки источника", "device.stream_settings.title": "Настройки источника",
"device.stream_selector.label": "Источник:", "device.stream_selector.label": "Источник:",
"device.stream_selector.hint": "Выберите источник, определяющий что это устройство захватывает и обрабатывает", "device.stream_selector.hint": "Выберите источник, определяющий что это устройство захватывает и обрабатывает",
"device.stream_selector.none": "-- Источник не назначен --", "device.stream_selector.none": "-- Источник не назначен --",
@@ -388,13 +390,13 @@
"streams.validate_image.validating": "Проверка...", "streams.validate_image.validating": "Проверка...",
"streams.validate_image.valid": "Изображение доступно", "streams.validate_image.valid": "Изображение доступно",
"streams.validate_image.invalid": "Изображение недоступно", "streams.validate_image.invalid": "Изображение недоступно",
"targets.title": "Цели", "targets.title": "Цели",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.", "targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
"targets.subtab.led": "LED", "targets.subtab.led": "LED",
"targets.section.devices": "💡 Устройства", "targets.section.devices": "Устройства",
"targets.section.color_strips": "🎞️ Источники цветовых полос", "targets.section.color_strips": "Источники цветовых полос",
"targets.section.targets": "Цели", "targets.section.targets": "Цели",
"targets.section.specific_settings": "Специальные настройки", "targets.section.specific_settings": "Специальные настройки",
"targets.add": "Добавить Цель", "targets.add": "Добавить Цель",
"targets.edit": "Редактировать Цель", "targets.edit": "Редактировать Цель",
@@ -444,7 +446,7 @@
"targets.metrics.frames": "Кадры", "targets.metrics.frames": "Кадры",
"targets.metrics.errors": "Ошибки", "targets.metrics.errors": "Ошибки",
"targets.subtab.key_colors": "Ключевые Цвета", "targets.subtab.key_colors": "Ключевые Цвета",
"targets.section.key_colors": "🎨 Цели Ключевых Цветов", "targets.section.key_colors": "Цели Ключевых Цветов",
"kc.add": "Добавить Цель Ключевых Цветов", "kc.add": "Добавить Цель Ключевых Цветов",
"kc.edit": "Редактировать Цель Ключевых Цветов", "kc.edit": "Редактировать Цель Ключевых Цветов",
"kc.name": "Имя Цели:", "kc.name": "Имя Цели:",
@@ -476,9 +478,9 @@
"kc.colors.none": "Цвета пока не извлечены", "kc.colors.none": "Цвета пока не извлечены",
"kc.test": "Тест", "kc.test": "Тест",
"kc.test.error": "Ошибка теста", "kc.test.error": "Ошибка теста",
"targets.section.pattern_templates": "📄 Шаблоны Паттернов", "targets.section.pattern_templates": "Шаблоны Паттернов",
"pattern.add": "📄 Добавить Шаблон Паттерна", "pattern.add": "Добавить Шаблон Паттерна",
"pattern.edit": "📄 Редактировать Шаблон Паттерна", "pattern.edit": "Редактировать Шаблон Паттерна",
"pattern.name": "Имя Шаблона:", "pattern.name": "Имя Шаблона:",
"pattern.name.placeholder": "Мой Шаблон Паттерна", "pattern.name.placeholder": "Мой Шаблон Паттерна",
"pattern.description_label": "Описание (необязательно):", "pattern.description_label": "Описание (необязательно):",
@@ -513,7 +515,7 @@
"overlay.stopped": "Визуализация наложения остановлена", "overlay.stopped": "Визуализация наложения остановлена",
"overlay.error.start": "Не удалось запустить наложение", "overlay.error.start": "Не удалось запустить наложение",
"overlay.error.stop": "Не удалось остановить наложение", "overlay.error.stop": "Не удалось остановить наложение",
"dashboard.title": "📊 Обзор", "dashboard.title": "Обзор",
"dashboard.section.targets": "Цели", "dashboard.section.targets": "Цели",
"dashboard.section.running": "Запущенные", "dashboard.section.running": "Запущенные",
"dashboard.section.stopped": "Остановленные", "dashboard.section.stopped": "Остановленные",
@@ -532,10 +534,9 @@
"dashboard.perf.gpu": "ГП", "dashboard.perf.gpu": "ГП",
"dashboard.perf.unavailable": "недоступно", "dashboard.perf.unavailable": "недоступно",
"dashboard.poll_interval": "Интервал обновления", "dashboard.poll_interval": "Интервал обновления",
"profiles.title": "Профили",
"profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",
"profiles.add": "\uD83D\uDCCB Добавить профиль", "profiles.add": "Добавить профиль",
"profiles.edit": "Редактировать профиль", "profiles.edit": "Редактировать профиль",
"profiles.delete.confirm": "Удалить профиль \"{name}\"?", "profiles.delete.confirm": "Удалить профиль \"{name}\"?",
"profiles.name": "Название:", "profiles.name": "Название:",
@@ -549,7 +550,7 @@
"profiles.conditions": "Условия:", "profiles.conditions": "Условия:",
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется", "profiles.conditions.hint": "Правила, определяющие когда профиль активируется",
"profiles.conditions.add": "Добавить условие", "profiles.conditions.add": "Добавить условие",
"profiles.conditions.empty": "Нет условий \u2014 профиль всегда активен когда включён", "profiles.conditions.empty": "Нет условий профиль всегда активен когда включён",
"profiles.condition.always": "Всегда", "profiles.condition.always": "Всегда",
"profiles.condition.always.hint": "Профиль активируется сразу при включении и остаётся активным. Используйте для автозапуска целей при старте сервера.", "profiles.condition.always.hint": "Профиль активируется сразу при включении и остаётся активным. Используйте для автозапуска целей при старте сервера.",
"profiles.condition.application": "Приложение", "profiles.condition.application": "Приложение",
@@ -597,9 +598,8 @@
"aria.previous": "Назад", "aria.previous": "Назад",
"aria.next": "Вперёд", "aria.next": "Вперёд",
"aria.hint": "Показать подсказку", "aria.hint": "Показать подсказку",
"color_strip.add": "Добавить источник цветовой полосы",
"color_strip.add": "🎞️ Добавить источник цветовой полосы", "color_strip.edit": "Редактировать источник цветовой полосы",
"color_strip.edit": "🎞️ Редактировать источник цветовой полосы",
"color_strip.name": "Название:", "color_strip.name": "Название:",
"color_strip.name.placeholder": "Настенная полоса", "color_strip.name.placeholder": "Настенная полоса",
"color_strip.picture_source": "Источник изображения:", "color_strip.picture_source": "Источник изображения:",
@@ -786,7 +786,6 @@
"color_strip.palette.aurora": "Аврора", "color_strip.palette.aurora": "Аврора",
"color_strip.palette.sunset": "Закат", "color_strip.palette.sunset": "Закат",
"color_strip.palette.ice": "Лёд", "color_strip.palette.ice": "Лёд",
"audio_source.title": "Аудиоисточники", "audio_source.title": "Аудиоисточники",
"audio_source.group.multichannel": "Многоканальные", "audio_source.group.multichannel": "Многоканальные",
"audio_source.group.mono": "Моно", "audio_source.group.mono": "Моно",
@@ -829,14 +828,12 @@
"audio_source.test.beat": "Бит", "audio_source.test.beat": "Бит",
"audio_source.test.connecting": "Подключение...", "audio_source.test.connecting": "Подключение...",
"audio_source.test.error": "Ошибка теста аудио", "audio_source.test.error": "Ошибка теста аудио",
"audio_template.test": "Тест", "audio_template.test": "Тест",
"audio_template.test.title": "Тест аудиошаблона", "audio_template.test.title": "Тест аудиошаблона",
"audio_template.test.device": "Аудиоустройство:", "audio_template.test.device": "Аудиоустройство:",
"audio_template.test.device.hint": "Выберите устройство для захвата звука во время теста", "audio_template.test.device.hint": "Выберите устройство для захвата звука во время теста",
"audio_template.test.run": "🧪 Запуск", "audio_template.test.run": "Запуск",
"audio_template.title": "Аудиошаблоны",
"audio_template.title": "🎵 Аудиошаблоны",
"audio_template.add": "Добавить аудиошаблон", "audio_template.add": "Добавить аудиошаблон",
"audio_template.edit": "Редактировать аудиошаблон", "audio_template.edit": "Редактировать аудиошаблон",
"audio_template.name": "Название шаблона:", "audio_template.name": "Название шаблона:",
@@ -857,9 +854,8 @@
"audio_template.error.engines": "Не удалось загрузить аудиодвижки", "audio_template.error.engines": "Не удалось загрузить аудиодвижки",
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля", "audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон", "audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений", "streams.group.value": "Источники значений",
"value_source.group.title": "🔢 Источники значений", "value_source.group.title": "Источники значений",
"value_source.add": "Добавить источник значений", "value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений", "value_source.edit": "Редактировать источник значений",
"value_source.name": "Название:", "value_source.name": "Название:",
@@ -947,7 +943,6 @@
"targets.protocol": "Протокол:", "targets.protocol": "Протокол:",
"targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.", "targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.",
"targets.protocol.serial": "Serial", "targets.protocol.serial": "Serial",
"search.open": "Поиск (Ctrl+K)", "search.open": "Поиск (Ctrl+K)",
"search.placeholder": "Поиск... (Ctrl+K)", "search.placeholder": "Поиск... (Ctrl+K)",
"search.loading": "Загрузка...", "search.loading": "Загрузка...",
@@ -963,8 +958,6 @@
"search.group.pattern_templates": "Шаблоны паттернов", "search.group.pattern_templates": "Шаблоны паттернов",
"search.group.audio": "Аудиоисточники", "search.group.audio": "Аудиоисточники",
"search.group.value": "Источники значений", "search.group.value": "Источники значений",
"settings.title": "Настройки",
"settings.backup.label": "Резервное копирование", "settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию", "settings.backup.button": "Скачать резервную копию",
@@ -979,7 +972,6 @@
"settings.restore.restarting": "Сервер перезапускается...", "settings.restore.restarting": "Сервер перезапускается...",
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
"settings.button.close": "Закрыть", "settings.button.close": "Закрыть",
"settings.auto_backup.label": "Авто-бэкап", "settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап", "settings.auto_backup.enable": "Включить авто-бэкап",
@@ -990,7 +982,6 @@
"settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа", "settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа",
"settings.auto_backup.last_backup": "Последний бэкап", "settings.auto_backup.last_backup": "Последний бэкап",
"settings.auto_backup.never": "Никогда", "settings.auto_backup.never": "Никогда",
"settings.saved_backups.label": "Сохранённые копии", "settings.saved_backups.label": "Сохранённые копии",
"settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.", "settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.",
"settings.saved_backups.empty": "Нет сохранённых копий", "settings.saved_backups.empty": "Нет сохранённых копий",

View File

@@ -3,6 +3,8 @@
"app.version": "版本:", "app.version": "版本:",
"app.api_docs": "API 文档", "app.api_docs": "API 文档",
"theme.toggle": "切换主题", "theme.toggle": "切换主题",
"accent.title": "主题色",
"accent.custom": "自定义",
"locale.change": "切换语言", "locale.change": "切换语言",
"auth.login": "登录", "auth.login": "登录",
"auth.logout": "退出", "auth.logout": "退出",
@@ -21,7 +23,7 @@
"auth.please_login": "请先登录", "auth.please_login": "请先登录",
"auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。", "auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。",
"displays.title": "可用显示器", "displays.title": "可用显示器",
"displays.layout": "\uD83D\uDDA5\uFE0F 显示器", "displays.layout": "显示器",
"displays.information": "显示器信息", "displays.information": "显示器信息",
"displays.legend.primary": "主显示器", "displays.legend.primary": "主显示器",
"displays.legend.secondary": "副显示器", "displays.legend.secondary": "副显示器",
@@ -44,7 +46,7 @@
"displays.picker.adb_connect.error": "连接设备失败", "displays.picker.adb_connect.error": "连接设备失败",
"displays.picker.adb_disconnect": "断开连接", "displays.picker.adb_disconnect": "断开连接",
"displays.picker.no_android": "未找到 Android 设备。请通过 USB 连接或在上方输入 IP 地址。", "displays.picker.no_android": "未找到 Android 设备。请通过 USB 连接或在上方输入 IP 地址。",
"templates.title": "\uD83D\uDCC4 引擎模板", "templates.title": "引擎模板",
"templates.description": "采集模板定义屏幕的采集方式。每个模板使用特定的采集引擎MSS、DXcam、WGC及自定义设置。将模板分配给设备以获得最佳性能。", "templates.description": "采集模板定义屏幕的采集方式。每个模板使用特定的采集引擎MSS、DXcam、WGC及自定义设置。将模板分配给设备以获得最佳性能。",
"templates.loading": "正在加载模板...", "templates.loading": "正在加载模板...",
"templates.empty": "尚未配置采集模板", "templates.empty": "尚未配置采集模板",
@@ -77,7 +79,7 @@
"templates.test.display.select": "选择显示器...", "templates.test.display.select": "选择显示器...",
"templates.test.duration": "采集时长(秒):", "templates.test.duration": "采集时长(秒):",
"templates.test.border_width": "边框宽度(像素):", "templates.test.border_width": "边框宽度(像素):",
"templates.test.run": "\uD83E\uDDEA 运行", "templates.test.run": "运行",
"templates.test.running": "正在运行测试...", "templates.test.running": "正在运行测试...",
"templates.test.results.preview": "全幅采集预览", "templates.test.results.preview": "全幅采集预览",
"templates.test.results.borders": "边框提取", "templates.test.results.borders": "边框提取",
@@ -97,7 +99,7 @@
"templates.test.error.no_engine": "请选择采集引擎", "templates.test.error.no_engine": "请选择采集引擎",
"templates.test.error.no_display": "请选择显示器", "templates.test.error.no_display": "请选择显示器",
"templates.test.error.failed": "测试失败", "templates.test.error.failed": "测试失败",
"devices.title": "\uD83D\uDCA1 设备", "devices.title": "设备",
"devices.add": "添加新设备", "devices.add": "添加新设备",
"devices.loading": "正在加载设备...", "devices.loading": "正在加载设备...",
"devices.none": "尚未配置设备", "devices.none": "尚未配置设备",
@@ -180,7 +182,7 @@
"device.tip.calibrate": "校准 LED 位置、方向和覆盖范围", "device.tip.calibrate": "校准 LED 位置、方向和覆盖范围",
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置", "device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.add": "点击此处添加新的 LED 设备", "device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设备设置", "settings.title": "设置",
"settings.general.title": "常规设置", "settings.general.title": "常规设置",
"settings.capture.title": "采集设置", "settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新", "settings.capture.saved": "采集设置已更新",
@@ -285,12 +287,12 @@
"section.filter.reset": "清除筛选", "section.filter.reset": "清除筛选",
"section.expand_all": "全部展开", "section.expand_all": "全部展开",
"section.collapse_all": "全部折叠", "section.collapse_all": "全部折叠",
"streams.title": "\uD83D\uDCFA 源", "streams.title": "源",
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。", "streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
"streams.group.raw": "屏幕采集", "streams.group.raw": "屏幕采集",
"streams.group.processed": "已处理", "streams.group.processed": "已处理",
"streams.group.audio": "音频", "streams.group.audio": "音频",
"streams.section.streams": "\uD83D\uDCFA 源", "streams.section.streams": "源",
"streams.add": "添加源", "streams.add": "添加源",
"streams.add.raw": "添加屏幕采集", "streams.add.raw": "添加屏幕采集",
"streams.add.processed": "添加处理源", "streams.add.processed": "添加处理源",
@@ -322,11 +324,11 @@
"streams.error.required": "请填写所有必填项", "streams.error.required": "请填写所有必填项",
"streams.error.delete": "删除源失败", "streams.error.delete": "删除源失败",
"streams.test.title": "测试源", "streams.test.title": "测试源",
"streams.test.run": "🧪 运行", "streams.test.run": "运行",
"streams.test.running": "正在测试源...", "streams.test.running": "正在测试源...",
"streams.test.duration": "采集时长(秒):", "streams.test.duration": "采集时长(秒):",
"streams.test.error.failed": "源测试失败", "streams.test.error.failed": "源测试失败",
"postprocessing.title": "\uD83D\uDCC4 滤镜模板", "postprocessing.title": "滤镜模板",
"postprocessing.description": "处理模板定义图像滤镜和色彩校正。将它们分配给处理图片源以实现跨设备的一致后处理。", "postprocessing.description": "处理模板定义图像滤镜和色彩校正。将它们分配给处理图片源以实现跨设备的一致后处理。",
"postprocessing.add": "添加滤镜模板", "postprocessing.add": "添加滤镜模板",
"postprocessing.edit": "编辑滤镜模板", "postprocessing.edit": "编辑滤镜模板",
@@ -363,7 +365,7 @@
"postprocessing.test.error.no_stream": "请选择一个源", "postprocessing.test.error.no_stream": "请选择一个源",
"postprocessing.test.error.failed": "处理模板测试失败", "postprocessing.test.error.failed": "处理模板测试失败",
"device.button.stream_selector": "源设置", "device.button.stream_selector": "源设置",
"device.stream_settings.title": "📺 源设置", "device.stream_settings.title": "源设置",
"device.stream_selector.label": "源:", "device.stream_selector.label": "源:",
"device.stream_selector.hint": "选择一个源来定义此设备采集和处理的内容", "device.stream_selector.hint": "选择一个源来定义此设备采集和处理的内容",
"device.stream_selector.none": "-- 未分配源 --", "device.stream_selector.none": "-- 未分配源 --",
@@ -388,13 +390,13 @@
"streams.validate_image.validating": "正在验证...", "streams.validate_image.validating": "正在验证...",
"streams.validate_image.valid": "图片可访问", "streams.validate_image.valid": "图片可访问",
"streams.validate_image.invalid": "图片不可访问", "streams.validate_image.invalid": "图片不可访问",
"targets.title": "目标", "targets.title": "目标",
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。", "targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
"targets.subtab.led": "LED", "targets.subtab.led": "LED",
"targets.section.devices": "💡 设备", "targets.section.devices": "设备",
"targets.section.color_strips": "🎞️ 色带源", "targets.section.color_strips": "色带源",
"targets.section.targets": "目标", "targets.section.targets": "目标",
"targets.section.specific_settings": "特定设置", "targets.section.specific_settings": "特定设置",
"targets.add": "添加目标", "targets.add": "添加目标",
"targets.edit": "编辑目标", "targets.edit": "编辑目标",
@@ -444,7 +446,7 @@
"targets.metrics.frames": "帧数", "targets.metrics.frames": "帧数",
"targets.metrics.errors": "错误", "targets.metrics.errors": "错误",
"targets.subtab.key_colors": "关键颜色", "targets.subtab.key_colors": "关键颜色",
"targets.section.key_colors": "🎨 关键颜色目标", "targets.section.key_colors": "关键颜色目标",
"kc.add": "添加关键颜色目标", "kc.add": "添加关键颜色目标",
"kc.edit": "编辑关键颜色目标", "kc.edit": "编辑关键颜色目标",
"kc.name": "目标名称:", "kc.name": "目标名称:",
@@ -476,9 +478,9 @@
"kc.colors.none": "尚未提取颜色", "kc.colors.none": "尚未提取颜色",
"kc.test": "测试", "kc.test": "测试",
"kc.test.error": "测试失败", "kc.test.error": "测试失败",
"targets.section.pattern_templates": "📄 图案模板", "targets.section.pattern_templates": "图案模板",
"pattern.add": "📄 添加图案模板", "pattern.add": "添加图案模板",
"pattern.edit": "📄 编辑图案模板", "pattern.edit": "编辑图案模板",
"pattern.name": "模板名称:", "pattern.name": "模板名称:",
"pattern.name.placeholder": "我的图案模板", "pattern.name.placeholder": "我的图案模板",
"pattern.description_label": "描述(可选):", "pattern.description_label": "描述(可选):",
@@ -513,7 +515,7 @@
"overlay.stopped": "叠加层可视化已停止", "overlay.stopped": "叠加层可视化已停止",
"overlay.error.start": "启动叠加层失败", "overlay.error.start": "启动叠加层失败",
"overlay.error.stop": "停止叠加层失败", "overlay.error.stop": "停止叠加层失败",
"dashboard.title": "📊 仪表盘", "dashboard.title": "仪表盘",
"dashboard.section.targets": "目标", "dashboard.section.targets": "目标",
"dashboard.section.running": "运行中", "dashboard.section.running": "运行中",
"dashboard.section.stopped": "已停止", "dashboard.section.stopped": "已停止",
@@ -532,10 +534,9 @@
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "不可用", "dashboard.perf.unavailable": "不可用",
"dashboard.poll_interval": "刷新间隔", "dashboard.poll_interval": "刷新间隔",
"profiles.title": "配置文件",
"profiles.title": "\uD83D\uDCCB 配置文件",
"profiles.empty": "尚未配置配置文件。创建一个以自动化目标激活。", "profiles.empty": "尚未配置配置文件。创建一个以自动化目标激活。",
"profiles.add": "\uD83D\uDCCB 添加配置文件", "profiles.add": "添加配置文件",
"profiles.edit": "编辑配置文件", "profiles.edit": "编辑配置文件",
"profiles.delete.confirm": "删除配置文件 \"{name}\"", "profiles.delete.confirm": "删除配置文件 \"{name}\"",
"profiles.name": "名称:", "profiles.name": "名称:",
@@ -597,9 +598,8 @@
"aria.previous": "上一个", "aria.previous": "上一个",
"aria.next": "下一个", "aria.next": "下一个",
"aria.hint": "显示提示", "aria.hint": "显示提示",
"color_strip.add": "添加色带源",
"color_strip.add": "🎞️ 添加色带源", "color_strip.edit": "编辑色带源",
"color_strip.edit": "🎞️ 编辑色带源",
"color_strip.name": "名称:", "color_strip.name": "名称:",
"color_strip.name.placeholder": "墙壁灯带", "color_strip.name.placeholder": "墙壁灯带",
"color_strip.picture_source": "图片源:", "color_strip.picture_source": "图片源:",
@@ -786,7 +786,6 @@
"color_strip.palette.aurora": "极光", "color_strip.palette.aurora": "极光",
"color_strip.palette.sunset": "日落", "color_strip.palette.sunset": "日落",
"color_strip.palette.ice": "冰", "color_strip.palette.ice": "冰",
"audio_source.title": "音频源", "audio_source.title": "音频源",
"audio_source.group.multichannel": "多声道", "audio_source.group.multichannel": "多声道",
"audio_source.group.mono": "单声道", "audio_source.group.mono": "单声道",
@@ -829,14 +828,12 @@
"audio_source.test.beat": "节拍", "audio_source.test.beat": "节拍",
"audio_source.test.connecting": "连接中...", "audio_source.test.connecting": "连接中...",
"audio_source.test.error": "音频测试失败", "audio_source.test.error": "音频测试失败",
"audio_template.test": "测试", "audio_template.test": "测试",
"audio_template.test.title": "测试音频模板", "audio_template.test.title": "测试音频模板",
"audio_template.test.device": "音频设备:", "audio_template.test.device": "音频设备:",
"audio_template.test.device.hint": "选择测试期间要采集的音频设备", "audio_template.test.device.hint": "选择测试期间要采集的音频设备",
"audio_template.test.run": "🧪 运行", "audio_template.test.run": "运行",
"audio_template.title": "音频模板",
"audio_template.title": "🎵 音频模板",
"audio_template.add": "添加音频模板", "audio_template.add": "添加音频模板",
"audio_template.edit": "编辑音频模板", "audio_template.edit": "编辑音频模板",
"audio_template.name": "模板名称:", "audio_template.name": "模板名称:",
@@ -857,9 +854,8 @@
"audio_template.error.engines": "加载音频引擎失败", "audio_template.error.engines": "加载音频引擎失败",
"audio_template.error.required": "请填写所有必填项", "audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败", "audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源", "streams.group.value": "值源",
"value_source.group.title": "🔢 值源", "value_source.group.title": "值源",
"value_source.add": "添加值源", "value_source.add": "添加值源",
"value_source.edit": "编辑值源", "value_source.edit": "编辑值源",
"value_source.name": "名称:", "value_source.name": "名称:",
@@ -947,7 +943,6 @@
"targets.protocol": "协议:", "targets.protocol": "协议:",
"targets.protocol.hint": "DDP通过快速UDP发送像素推荐。HTTP使用JSON API——较慢但可靠限制约500个LED。", "targets.protocol.hint": "DDP通过快速UDP发送像素推荐。HTTP使用JSON API——较慢但可靠限制约500个LED。",
"targets.protocol.serial": "串口", "targets.protocol.serial": "串口",
"search.open": "搜索 (Ctrl+K)", "search.open": "搜索 (Ctrl+K)",
"search.placeholder": "搜索实体... (Ctrl+K)", "search.placeholder": "搜索实体... (Ctrl+K)",
"search.loading": "加载中...", "search.loading": "加载中...",
@@ -963,8 +958,6 @@
"search.group.pattern_templates": "图案模板", "search.group.pattern_templates": "图案模板",
"search.group.audio": "音频源", "search.group.audio": "音频源",
"search.group.value": "值源", "search.group.value": "值源",
"settings.title": "设置",
"settings.backup.label": "备份配置", "settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。", "settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份", "settings.backup.button": "下载备份",
@@ -979,7 +972,6 @@
"settings.restore.restarting": "服务器正在重启...", "settings.restore.restarting": "服务器正在重启...",
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
"settings.button.close": "关闭", "settings.button.close": "关闭",
"settings.auto_backup.label": "自动备份", "settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份", "settings.auto_backup.enable": "启用自动备份",
@@ -990,7 +982,6 @@
"settings.auto_backup.save_error": "保存自动备份设置失败", "settings.auto_backup.save_error": "保存自动备份设置失败",
"settings.auto_backup.last_backup": "上次备份", "settings.auto_backup.last_backup": "上次备份",
"settings.auto_backup.never": "从未", "settings.auto_backup.never": "从未",
"settings.saved_backups.label": "已保存的备份", "settings.saved_backups.label": "已保存的备份",
"settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。", "settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。",
"settings.saved_backups.empty": "没有已保存的备份", "settings.saved_backups.empty": "没有已保存的备份",

View File

@@ -29,16 +29,39 @@
<div class="server-info"> <div class="server-info">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a> <a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<button class="search-toggle" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial"> <button class="search-toggle" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
? <svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button> </button>
<button class="search-toggle" onclick="openCommandPalette()" data-i18n-title="search.open" title="Search (Ctrl+K)"> <button class="search-toggle" onclick="openCommandPalette()" data-i18n-title="search.open" title="Search (Ctrl+K)">
🔍 <svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</button> </button>
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme"> <button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span> <span id="theme-icon"><svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg></span>
</button> </button>
<div class="accent-wrapper">
<button class="search-toggle" onclick="toggleAccentPicker()" data-i18n-title="accent.title" title="Accent color">
<span id="accent-swatch" class="accent-swatch" style="background: var(--primary-color)"></span>
</button>
<div id="accent-popover" class="accent-popover" style="display:none">
<div class="accent-grid">
<button class="accent-dot" style="background:#4CAF50" onclick="pickAccent('#4CAF50')"></button>
<button class="accent-dot" style="background:#7C4DFF" onclick="pickAccent('#7C4DFF')"></button>
<button class="accent-dot" style="background:#FF6D00" onclick="pickAccent('#FF6D00')"></button>
<button class="accent-dot" style="background:#E91E63" onclick="pickAccent('#E91E63')"></button>
<button class="accent-dot" style="background:#00BCD4" onclick="pickAccent('#00BCD4')"></button>
<button class="accent-dot" style="background:#FF5252" onclick="pickAccent('#FF5252')"></button>
<button class="accent-dot" style="background:#26A69A" onclick="pickAccent('#26A69A')"></button>
<button class="accent-dot" style="background:#2196F3" onclick="pickAccent('#2196F3')"></button>
<button class="accent-dot" style="background:#FFC107" onclick="pickAccent('#FFC107')"></button>
</div>
<div class="accent-custom">
<input type="color" id="accent-picker" value="#4CAF50"
oninput="pickAccent(this.value)" onchange="pickAccent(this.value)">
<span data-i18n="accent.custom">Custom</span>
</div>
</div>
</div>
<button class="search-toggle" onclick="openSettingsModal()" data-i18n-title="settings.title" title="Settings"> <button class="search-toggle" onclick="openSettingsModal()" data-i18n-title="settings.title" title="Settings">
&#x2699;&#xFE0F; <svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;"> <select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;">
<option value="en">English</option> <option value="en">English</option>
@@ -46,20 +69,20 @@
<option value="zh">中文</option> <option value="zh">中文</option>
</select> </select>
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 4px 12px; font-size: 0.8rem;"> <button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 4px 12px; font-size: 0.8rem;">
🔑 <span data-i18n="auth.login">Login</span> <svg class="icon" viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg> <span data-i18n="auth.login">Login</span>
</button> </button>
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 4px 12px; font-size: 0.8rem;"> <button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 4px 12px; font-size: 0.8rem;">
🚪 <svg class="icon" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>
</button> </button>
</div> </div>
</header> </header>
<div class="tabs"> <div class="tabs">
<div class="tab-bar" role="tablist"> <div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><span data-i18n="dashboard.title">📊 Dashboard</span></button> <button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><span data-i18n="profiles.title">📋 Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button> <button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><span data-i18n="streams.title">📺 Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
</div> </div>
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard"> <div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
@@ -158,7 +181,9 @@
function updateThemeIcon(theme) { function updateThemeIcon(theme) {
const icon = document.getElementById('theme-icon'); const icon = document.getElementById('theme-icon');
icon.textContent = theme === 'dark' ? '☀️' : '🌙'; icon.innerHTML = theme === 'dark'
? '<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>'
: '<svg class="icon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>';
} }
function toggleTheme() { function toggleTheme() {
@@ -168,9 +193,90 @@
document.documentElement.setAttribute('data-theme', newTheme); document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme); updateThemeIcon(newTheme);
// Re-derive accent text variant for the new theme
const accent = localStorage.getItem('accentColor');
if (accent) applyAccentColor(accent, true);
showToast(`Switched to ${newTheme} theme`, 'info'); showToast(`Switched to ${newTheme} theme`, 'info');
} }
// Initialize accent color
function adjustLightness(hex, amount) {
const r = parseInt(hex.slice(1,3),16)/255;
const g = parseInt(hex.slice(3,5),16)/255;
const b = parseInt(hex.slice(5,7),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
let h, s, l = (max+min)/2;
if (max===min) { h=s=0; } else {
const d = max-min;
s = l>0.5 ? d/(2-max-min) : d/(max+min);
if (max===r) h=((g-b)/d+(g<b?6:0))/6;
else if (max===g) h=((b-r)/d+2)/6;
else h=((r-g)/d+4)/6;
}
l = Math.max(0, Math.min(1, l + amount/100));
const hue2rgb = (p,q,t) => { if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<1/2)return q; if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; };
let rr,gg,bb;
if (s===0) { rr=gg=bb=l; } else {
const q = l<0.5 ? l*(1+s) : l+s-l*s, p = 2*l-q;
rr=hue2rgb(p,q,h+1/3); gg=hue2rgb(p,q,h); bb=hue2rgb(p,q,h-1/3);
}
return '#'+[rr,gg,bb].map(x=>Math.round(x*255).toString(16).padStart(2,'0')).join('');
}
function applyAccentColor(hex, silent) {
const root = document.documentElement;
root.style.setProperty('--primary-color', hex);
const theme = root.getAttribute('data-theme');
root.style.setProperty('--primary-text-color', adjustLightness(hex, theme === 'dark' ? 15 : -15));
root.style.setProperty('--primary-hover', adjustLightness(hex, 8));
document.getElementById('accent-swatch').style.background = hex;
document.getElementById('accent-picker').value = hex;
// Mark the active preset dot
document.querySelectorAll('.accent-dot').forEach(d => {
d.classList.toggle('active', d.style.background === hex || d.style.backgroundColor === hex
|| d.style.background.toLowerCase() === hex.toLowerCase());
});
localStorage.setItem('accentColor', hex);
if (!silent) showToast('Accent color updated', 'info');
}
function toggleAccentPicker() {
const pop = document.getElementById('accent-popover');
const show = pop.style.display === 'none';
pop.style.display = show ? '' : 'none';
if (show) {
// Mark active dot on open
const cur = localStorage.getItem('accentColor') || '#4CAF50';
document.querySelectorAll('.accent-dot').forEach(d => {
const dColor = d.style.backgroundColor || d.style.background;
d.classList.toggle('active', rgbToHex(dColor) === cur.toUpperCase());
});
}
}
function rgbToHex(rgb) {
if (rgb.startsWith('#')) return rgb.toUpperCase();
const m = rgb.match(/\d+/g);
if (!m) return rgb;
return '#' + m.slice(0,3).map(n => parseInt(n).toString(16).padStart(2,'0')).join('').toUpperCase();
}
function pickAccent(hex) {
applyAccentColor(hex);
document.getElementById('accent-popover').style.display = 'none';
}
// Close popover on outside click
document.addEventListener('click', function(e) {
const wrapper = document.querySelector('.accent-wrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('accent-popover').style.display = 'none';
}
});
const savedAccent = localStorage.getItem('accentColor');
if (savedAccent) applyAccentColor(savedAccent, true);
// Initialize auth state // Initialize auth state
function updateAuthUI() { function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key'); const apiKey = localStorage.getItem('wled_api_key');
@@ -228,10 +334,10 @@
const button = document.querySelector('.password-toggle'); const button = document.querySelector('.password-toggle');
if (input.type === 'password') { if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
button.textContent = '🙈'; button.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>';
} else { } else {
input.type = 'password'; input.type = 'password';
button.textContent = '👁️'; button.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>';
} }
} }

View File

@@ -4,7 +4,7 @@
<div class="modal-header"> <div class="modal-header">
<h2 id="add-device-modal-title" data-i18n="devices.add">Add New Device</h2> <h2 id="add-device-modal-title" data-i18n="devices.add">Add New Device</h2>
<div class="modal-header-actions"> <div class="modal-header-actions">
<button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery">&#x1F50D;</button> <button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery"><svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg></button>
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div id="api-key-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="api-key-modal-title"> <div id="api-key-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="api-key-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="api-key-modal-title" data-i18n="auth.title">🔑 Login to LED Grab</h2> <h2 id="api-key-modal-title"><svg class="icon" viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg> <span data-i18n="auth.title">Login to LED Grab</span></h2>
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<form id="api-key-form" onsubmit="submitApiKey(event)"> <form id="api-key-form" onsubmit="submitApiKey(event)">
@@ -25,7 +25,7 @@
autocomplete="off" autocomplete="off"
> >
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()"> <button type="button" class="password-toggle" onclick="togglePasswordVisibility()">
👁️ <svg class="icon" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div id="calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="calibration-modal-title"> <div id="calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="calibration-modal-title">
<div class="modal-content" style="max-width: 700px;"> <div class="modal-content" style="max-width: 700px;">
<div class="modal-header"> <div class="modal-header">
<h2 id="calibration-modal-title" data-i18n="calibration.title">📐 LED Calibration</h2> <h2 id="calibration-modal-title"><svg class="icon" viewBox="0 0 24 24"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg> <span data-i18n="calibration.title">LED Calibration</span></h2>
<button id="calibration-tutorial-btn" class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button> <button id="calibration-tutorial-btn" class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button>
<button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
@@ -34,7 +34,7 @@
<!-- Screen with direction toggle, total LEDs, and offset --> <!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen"> <div class="preview-screen">
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction"> <button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<span id="direction-icon"></span> <span id="direction-label">CW</span> <span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
</button> </button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div> <div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width"> <div class="preview-screen-border-width">
@@ -42,7 +42,7 @@
<input type="number" id="cal-border-width" min="1" max="100" value="10"> <input type="number" id="cal-border-width" min="1" max="100" value="10">
</div> </div>
<button id="calibration-overlay-btn" class="calibration-overlay-toggle" onclick="toggleCalibrationOverlay()" data-i18n-title="overlay.button.show" title="Show overlay visualization" style="display:none"> <button id="calibration-overlay-btn" class="calibration-overlay-toggle" onclick="toggleCalibrationOverlay()" data-i18n-title="overlay.button.show" title="Show overlay visualization" style="display:none">
&#x1F4A1; <span data-i18n="calibration.overlay_toggle">Overlay</span> <svg class="icon" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg> <span data-i18n="calibration.overlay_toggle">Overlay</span>
</button> </button>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div id="css-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="css-editor-title"> <div id="css-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="css-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="css-editor-title" data-i18n="color_strip.add">🎞️ Add Color Strip Source</h2> <h2 id="css-editor-title"><svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/></svg> <span data-i18n="color_strip.add">Add Color Strip Source</span></h2>
<button class="modal-close-btn" onclick="closeCSSEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeCSSEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -2,7 +2,7 @@
<div id="device-settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-modal-title"> <div id="device-settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="device-settings-modal-title" data-i18n="settings.general.title">⚙️ General Settings</h2> <h2 id="device-settings-modal-title"><svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg> <span data-i18n="settings.general.title">General Settings</span></h2>
<button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -2,7 +2,7 @@
<div id="kc-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="kc-editor-title"> <div id="kc-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="kc-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2> <h2 id="kc-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg> <span data-i18n="kc.add">Add Key Colors Target</span></h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -2,7 +2,7 @@
<div id="pattern-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pattern-template-modal-title"> <div id="pattern-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pattern-template-modal-title">
<div class="modal-content modal-content-wide"> <div class="modal-content modal-content-wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="pattern-template-modal-title" data-i18n="pattern.add">📄 Add Pattern Template</h2> <h2 id="pattern-template-modal-title"><svg class="icon" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg> <span data-i18n="pattern.add">Add Pattern Template</span></h2>
<button class="modal-close-btn" onclick="closePatternTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closePatternTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -36,7 +36,7 @@
<small class="input-hint" style="display:none" data-i18n="pattern.visual_editor.hint">Click + buttons to add rectangles. Drag edges to resize, drag inside to move.</small> <small class="input-hint" style="display:none" data-i18n="pattern.visual_editor.hint">Click + buttons to add rectangles. Drag edges to resize, drag inside to move.</small>
<div class="pattern-bg-row"> <div class="pattern-bg-row">
<select id="pattern-bg-source"></select> <select id="pattern-bg-source"></select>
<button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg">📷</button> <button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg"><svg class="icon" viewBox="0 0 24 24"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg></button>
</div> </div>
<div class="pattern-canvas-container"> <div class="pattern-canvas-container">
<canvas id="pattern-canvas"></canvas> <canvas id="pattern-canvas"></canvas>

View File

@@ -2,7 +2,7 @@
<div id="profile-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title"> <div id="profile-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2> <h2 id="profile-editor-title"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.add">Add Profile</span></h2>
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -2,7 +2,7 @@
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title"> <div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="target-editor-title" data-i18n="targets.add">🎯 Add Target</h2> <h2 id="target-editor-title"><svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> <span data-i18n="targets.add">Add Target</span></h2>
<button class="modal-close-btn" onclick="closeTargetEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTargetEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -16,7 +16,7 @@
</div> </div>
<button type="button" id="test-audio-template-start-btn" class="btn btn-primary" onclick="startAudioTemplateTest()" style="margin-top: 8px;"> <button type="button" id="test-audio-template-start-btn" class="btn btn-primary" onclick="startAudioTemplateTest()" style="margin-top: 8px;">
<span data-i18n="audio_template.test.run">🧪 Run</span> <svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="audio_template.test.run">Run</span>
</button> </button>
<canvas id="audio-template-test-canvas" class="audio-test-canvas" style="display:none; margin-top: 12px;"></canvas> <canvas id="audio-template-test-canvas" class="audio-test-canvas" style="display:none; margin-top: 12px;"></canvas>

View File

@@ -19,7 +19,7 @@
</div> </div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;"> <button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run</span> <svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="streams.test.run">Run</span>
</button> </button>
</div> </div>

View File

@@ -15,7 +15,7 @@
</div> </div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;"> <button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run</span> <svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="streams.test.run">Run</span>
</button> </button>
</div> </div>

View File

@@ -23,7 +23,7 @@
</div> </div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;"> <button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<span data-i18n="templates.test.run">🧪 Run</span> <svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="templates.test.run">Run</span>
</button> </button>
</div> </div>