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:
2026-02-12 15:44:11 +03:00
parent 55814a3c30
commit 58df163ded
9 changed files with 176 additions and 142 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -1,4 +1,4 @@
"""Configuration management for WLED Grab."""
"""Configuration management for LED Grab."""
import os
from pathlib import Path

View File

@@ -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",

View File

@@ -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 ? `

View File

@@ -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) {

View File

@@ -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...",

View File

@@ -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": "Загрузка целей...",

View File

@@ -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;