Rename WLED Grab to LED Grab, merge Devices into Targets tab with WLED sub-tab, and UI polish
- Rename "WLED Grab" to "LED Grab" across all files (title, logs, locales) - Merge Devices and Targets into a single Targets tab with WLED sub-tab containing Devices and Targets sections (like Sources tab pattern) - Make target card source name label full-width - Render engine template config as two-column key-value grid - Update CLAUDE.md: no server restart needed for frontend-only changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,9 @@
|
||||
|
||||
## IMPORTANT: Auto-Restart Server on Code Changes
|
||||
|
||||
**Whenever server-side Python code is modified** (any file under `/server/src/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
|
||||
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
|
||||
|
||||
**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
|
||||
|
||||
### Restart procedure
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""WLED Grab - Ambient lighting based on screen content."""
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Configuration management for WLED Grab."""
|
||||
"""Configuration management for LED Grab."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -107,7 +107,7 @@ async def lifespan(app: FastAPI):
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
logger.info(f"Starting WLED Grab v{__version__}")
|
||||
logger.info(f"Starting LED Grab v{__version__}")
|
||||
logger.info(f"Python version: {sys.version}")
|
||||
logger.info(f"Server listening on {config.server.host}:{config.server.port}")
|
||||
|
||||
@@ -181,7 +181,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down WLED Grab")
|
||||
logger.info("Shutting down LED Grab")
|
||||
|
||||
# Stop all processing
|
||||
try:
|
||||
@@ -192,7 +192,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="WLED Grab",
|
||||
title="LED Grab",
|
||||
description="Control WLED devices based on screen content for ambient lighting",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
@@ -246,7 +246,7 @@ async def root():
|
||||
|
||||
# Fallback to JSON if static files not found
|
||||
return {
|
||||
"name": "WLED Grab",
|
||||
"name": "LED Grab",
|
||||
"version": __version__,
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
|
||||
@@ -288,7 +288,7 @@ const supportedLocales = {
|
||||
|
||||
// Minimal inline fallback for critical UI elements
|
||||
const fallbackTranslations = {
|
||||
'app.title': 'WLED Grab',
|
||||
'app.title': 'LED Grab',
|
||||
'auth.placeholder': 'Enter your API key...',
|
||||
'auth.button.login': 'Login'
|
||||
};
|
||||
@@ -400,7 +400,7 @@ function updateAllText() {
|
||||
// Re-render dynamic content with new translations
|
||||
if (apiKey) {
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
loadTargetsTab();
|
||||
loadPictureSources();
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// User is logged in, load data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
loadTargetsTab();
|
||||
|
||||
// Start auto-refresh
|
||||
startAutoRefresh();
|
||||
@@ -581,12 +581,14 @@ function switchTab(name) {
|
||||
if (name === 'streams') {
|
||||
loadPictureSources();
|
||||
} else if (name === 'targets') {
|
||||
loadTargets();
|
||||
loadTargetsTab();
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
const saved = localStorage.getItem('activeTab');
|
||||
let saved = localStorage.getItem('activeTab');
|
||||
// Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
|
||||
if (saved === 'devices') saved = 'targets';
|
||||
if (saved && document.getElementById(`tab-${saved}`)) {
|
||||
switchTab(saved);
|
||||
}
|
||||
@@ -595,68 +597,8 @@ function initTabs() {
|
||||
|
||||
// Load devices
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
const container = document.getElementById('devices-list');
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddDevice()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch state for each device
|
||||
const devicesWithState = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
try {
|
||||
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const state = stateResponse.ok ? await stateResponse.json() : {};
|
||||
return { ...device, state };
|
||||
} catch (error) {
|
||||
console.error(`Failed to load state for device ${device.id}:`, error);
|
||||
return device;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('')
|
||||
+ `<div class="card add-device-card" onclick="showAddDevice()">
|
||||
<div class="add-device-icon">+</div>
|
||||
<div class="add-device-label">${t('devices.add')}</div>
|
||||
</div>`;
|
||||
|
||||
// Update footer WLED Web UI link with first device's URL
|
||||
const webuiLink = document.querySelector('.wled-webui-link');
|
||||
if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) {
|
||||
webuiLink.href = devicesWithState[0].url;
|
||||
webuiLink.target = '_blank';
|
||||
webuiLink.rel = 'noopener';
|
||||
}
|
||||
|
||||
// Attach event listeners and fetch real WLED brightness
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load devices:', error);
|
||||
document.getElementById('devices-list').innerHTML =
|
||||
`<div class="loading">${t('devices.failed')}</div>`;
|
||||
}
|
||||
// Devices now render inside the combined Targets tab
|
||||
await loadTargetsTab();
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
@@ -1000,11 +942,9 @@ function startAutoRefresh() {
|
||||
refreshInterval = setInterval(() => {
|
||||
// Only refresh if user is authenticated
|
||||
if (apiKey) {
|
||||
const activeTab = localStorage.getItem('activeTab') || 'devices';
|
||||
if (activeTab === 'devices') {
|
||||
loadDevices();
|
||||
} else if (activeTab === 'targets') {
|
||||
loadTargets();
|
||||
const activeTab = localStorage.getItem('activeTab') || 'targets';
|
||||
if (activeTab === 'targets') {
|
||||
loadTargetsTab();
|
||||
}
|
||||
}
|
||||
}, 2000); // Refresh every 2 seconds
|
||||
@@ -2514,13 +2454,14 @@ async function onEngineChange() {
|
||||
configSection.style.display = 'none';
|
||||
return;
|
||||
} else {
|
||||
let gridHtml = '<div class="config-grid">';
|
||||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||||
|
||||
const fieldHtml = `
|
||||
<div class="form-group">
|
||||
<label for="config-${key}">${key}:</label>
|
||||
gridHtml += `
|
||||
<label class="config-grid-label" for="config-${key}">${key}</label>
|
||||
<div class="config-grid-value">
|
||||
${typeof value === 'boolean' ? `
|
||||
<select id="config-${key}" data-config-key="${key}">
|
||||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||||
@@ -2529,11 +2470,11 @@ async function onEngineChange() {
|
||||
` : `
|
||||
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||||
`}
|
||||
<small class="form-hint">${t('templates.config.default')}: ${JSON.stringify(value)}</small>
|
||||
</div>
|
||||
`;
|
||||
configFields.innerHTML += fieldHtml;
|
||||
});
|
||||
gridHtml += '</div>';
|
||||
configFields.innerHTML = gridHtml;
|
||||
}
|
||||
|
||||
configSection.style.display = 'block';
|
||||
@@ -3998,27 +3939,66 @@ async function saveTargetEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PICTURE TARGETS =====
|
||||
// ===== TARGETS TAB (WLED devices + targets combined) =====
|
||||
|
||||
async function loadTargets() {
|
||||
// Alias for backward compatibility
|
||||
await loadTargetsTab();
|
||||
}
|
||||
|
||||
function switchTargetSubTab(tabKey) {
|
||||
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
|
||||
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
|
||||
);
|
||||
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
|
||||
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
||||
);
|
||||
localStorage.setItem('activeTargetSubTab', tabKey);
|
||||
}
|
||||
|
||||
async function loadTargetsTab() {
|
||||
const container = document.getElementById('targets-panel-content');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
|
||||
// Fetch devices, targets, and sources in parallel
|
||||
const [devicesResp, targetsResp, sourcesResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
]);
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
|
||||
const data = await response.json();
|
||||
const targets = data.targets || [];
|
||||
|
||||
const container = document.getElementById('targets-list');
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>`;
|
||||
if (devicesResp.status === 401 || targetsResp.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch state for each target
|
||||
const devicesData = await devicesResp.json();
|
||||
const devices = devicesData.devices || [];
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
|
||||
let sourceMap = {};
|
||||
if (sourcesResp && sourcesResp.ok) {
|
||||
const srcData = await sourcesResp.json();
|
||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
||||
}
|
||||
|
||||
// Fetch state for each device
|
||||
const devicesWithState = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
try {
|
||||
const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
|
||||
const state = stateResp.ok ? await stateResp.json() : {};
|
||||
return { ...device, state };
|
||||
} catch {
|
||||
return device;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch state + metrics for each target
|
||||
const targetsWithState = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
@@ -4033,31 +4013,58 @@ async function loadTargets() {
|
||||
})
|
||||
);
|
||||
|
||||
// Also fetch devices and sources for name resolution
|
||||
let deviceMap = {};
|
||||
let sourceMap = {};
|
||||
try {
|
||||
const devResp = await fetch(`${API_BASE}/devices`, { headers: getHeaders() });
|
||||
if (devResp.ok) {
|
||||
const devData = await devResp.json();
|
||||
(devData.devices || []).forEach(d => { deviceMap[d.id] = d; });
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const srcResp = await fetchWithAuth('/picture-sources');
|
||||
if (srcResp.ok) {
|
||||
const srcData = await srcResp.json();
|
||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
||||
}
|
||||
} catch {}
|
||||
// Build device map for target name resolution
|
||||
const deviceMap = {};
|
||||
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
||||
|
||||
// Group by type (currently only WLED)
|
||||
const wledDevices = devicesWithState;
|
||||
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
|
||||
|
||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||
];
|
||||
|
||||
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>`
|
||||
).join('')}</div>`;
|
||||
|
||||
// WLED panel: devices section + targets section
|
||||
const wledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledDevices.map(device => createDeviceCard(device)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddDevice()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel;
|
||||
|
||||
// Attach event listeners and fetch WLED brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
});
|
||||
|
||||
container.innerHTML = targetsWithState.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')
|
||||
+ `<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>`;
|
||||
} catch (error) {
|
||||
console.error('Failed to load targets:', error);
|
||||
document.getElementById('targets-list').innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
|
||||
console.error('Failed to load targets tab:', error);
|
||||
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4095,7 +4102,7 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WLED Grab</title>
|
||||
<title>LED Grab</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
@@ -12,7 +12,7 @@
|
||||
<header>
|
||||
<div class="header-title">
|
||||
<span id="server-status" class="status-badge">●</span>
|
||||
<h1 data-i18n="app.title">WLED Grab</h1>
|
||||
<h1 data-i18n="app.title">LED Grab</h1>
|
||||
<span id="server-version"><span id="version-number"></span></span>
|
||||
</div>
|
||||
<div class="server-info">
|
||||
@@ -34,19 +34,12 @@
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
|
||||
<button class="tab-btn active" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel active" id="tab-devices">
|
||||
<div id="devices-list" class="devices-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-targets">
|
||||
<div id="targets-list" class="devices-grid">
|
||||
<div class="tab-panel active" id="tab-targets">
|
||||
<div id="targets-panel-content">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +314,7 @@
|
||||
<form id="api-key-form" onsubmit="submitApiKey(event)">
|
||||
<div class="modal-body">
|
||||
<p class="modal-description" data-i18n="auth.message">
|
||||
Please enter your API key to authenticate and access the WLED Grab.
|
||||
Please enter your API key to authenticate and access the LED Grab.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
@@ -761,7 +754,7 @@
|
||||
showToast(t('auth.logout.success'), 'info');
|
||||
|
||||
// Clear the UI
|
||||
document.getElementById('devices-list').innerHTML = `<div class="loading">${t('auth.please_login')} devices</div>`;
|
||||
document.getElementById('targets-panel-content').innerHTML = `<div class="loading">${t('auth.please_login')}</div>`;
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
@@ -837,7 +830,7 @@
|
||||
// Reload data
|
||||
loadServerInfo();
|
||||
loadDisplays();
|
||||
loadDevices();
|
||||
loadTargetsTab();
|
||||
|
||||
// Start auto-refresh if not already running
|
||||
if (!refreshInterval) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"app.title": "WLED Grab",
|
||||
"app.title": "LED Grab",
|
||||
"app.version": "Version:",
|
||||
"theme.toggle": "Toggle theme",
|
||||
"locale.change": "Change language",
|
||||
@@ -7,7 +7,7 @@
|
||||
"auth.logout": "Logout",
|
||||
"auth.authenticated": "● Authenticated",
|
||||
"auth.title": "Login to WLED Controller",
|
||||
"auth.message": "Please enter your API key to authenticate and access the WLED Grab.",
|
||||
"auth.message": "Please enter your API key to authenticate and access the LED Grab.",
|
||||
"auth.label": "API Key:",
|
||||
"auth.placeholder": "Enter your API key...",
|
||||
"auth.hint": "Your API key will be stored securely in your browser's local storage.",
|
||||
@@ -301,6 +301,9 @@
|
||||
"streams.validate_image.invalid": "Image not accessible",
|
||||
"targets.title": "⚡ Targets",
|
||||
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
|
||||
"targets.subtab.wled": "WLED",
|
||||
"targets.section.devices": "💡 Devices",
|
||||
"targets.section.targets": "⚡ Targets",
|
||||
"targets.add": "Add Target",
|
||||
"targets.edit": "Edit Target",
|
||||
"targets.loading": "Loading targets...",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"app.title": "WLED Grab",
|
||||
"app.title": "LED Grab",
|
||||
"app.version": "Версия:",
|
||||
"theme.toggle": "Переключить тему",
|
||||
"locale.change": "Изменить язык",
|
||||
"auth.login": "Войти",
|
||||
"auth.logout": "Выйти",
|
||||
"auth.authenticated": "● Авторизован",
|
||||
"auth.title": "Вход в WLED Grab",
|
||||
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к WLED Grab.",
|
||||
"auth.title": "Вход в LED Grab",
|
||||
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.",
|
||||
"auth.label": "API Ключ:",
|
||||
"auth.placeholder": "Введите ваш API ключ...",
|
||||
"auth.hint": "Ваш API ключ будет безопасно сохранен в локальном хранилище браузера.",
|
||||
@@ -301,6 +301,9 @@
|
||||
"streams.validate_image.invalid": "Изображение недоступно",
|
||||
"targets.title": "⚡ Цели",
|
||||
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
|
||||
"targets.subtab.wled": "WLED",
|
||||
"targets.section.devices": "💡 Устройства",
|
||||
"targets.section.targets": "⚡ Цели",
|
||||
"targets.add": "Добавить Цель",
|
||||
"targets.edit": "Редактировать Цель",
|
||||
"targets.loading": "Загрузка целей...",
|
||||
|
||||
@@ -1934,6 +1934,32 @@ input:-webkit-autofill:focus {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Engine config grid (property name left, input right) */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-grid-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.config-grid-value input,
|
||||
.config-grid-value select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.template-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user