diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 940b67d..2a76bac 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -34,6 +34,9 @@ function setupBackdropClose(modal, closeFn) { modal._backdropCloseSetup = true; } +// Device type helpers +function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; } + // Track logged errors to avoid console spam const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } @@ -598,6 +601,8 @@ function switchTab(name) { loadPictureSources(); } else if (name === 'targets') { loadTargetsTab(); + } else if (name === 'profiles') { + loadProfiles(); } } } @@ -767,7 +772,7 @@ async function showSettings(deviceId) { } const device = await deviceResponse.json(); - const isAdalight = device.device_type === 'adalight'; + const isAdalight = isSerialDevice(device.device_type); // Populate fields document.getElementById('settings-device-id').value = device.id; @@ -847,7 +852,7 @@ async function showSettings(deviceId) { } function _getSettingsUrl() { - if (settingsInitialValues.device_type === 'adalight') { + if (isSerialDevice(settingsInitialValues.device_type)) { return document.getElementById('settings-serial-port').value; } return document.getElementById('settings-device-url').value.trim(); @@ -902,7 +907,7 @@ async function saveDeviceSettings() { if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) { body.led_count = parseInt(ledCountInput.value, 10); } - if (settingsInitialValues.device_type === 'adalight') { + if (isSerialDevice(settingsInitialValues.device_type)) { const baudVal = document.getElementById('settings-baud-rate').value; if (baudVal) body.baud_rate = parseInt(baudVal, 10); } @@ -1044,7 +1049,7 @@ function onDeviceTypeChanged() { const baudRateGroup = document.getElementById('device-baud-rate-group'); - if (deviceType === 'adalight') { + if (isSerialDevice(deviceType)) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = ''; @@ -1081,14 +1086,16 @@ function onDeviceTypeChanged() { } } -function _computeMaxFps(baudRate, ledCount) { +function _computeMaxFps(baudRate, ledCount, deviceType) { if (!baudRate || !ledCount || ledCount < 1) return null; - const bitsPerFrame = (ledCount * 3 + 6) * 10; + // Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command + const overhead = deviceType === 'ambiled' ? 1 : 6; + const bitsPerFrame = (ledCount * 3 + overhead) * 10; return Math.floor(baudRate / bitsPerFrame); } -function _renderFpsHint(hintEl, baudRate, ledCount) { - const fps = _computeMaxFps(baudRate, ledCount); +function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) { + const fps = _computeMaxFps(baudRate, ledCount, deviceType); if (fps !== null) { hintEl.textContent = `Max FPS ≈ ${fps}`; hintEl.style.display = ''; @@ -1101,22 +1108,23 @@ function updateBaudFpsHint() { const hintEl = document.getElementById('baud-fps-hint'); const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10); const ledCount = parseInt(document.getElementById('device-led-count').value, 10); - _renderFpsHint(hintEl, baudRate, ledCount); + const deviceType = document.getElementById('device-type')?.value || 'adalight'; + _renderFpsHint(hintEl, baudRate, ledCount, deviceType); } function updateSettingsBaudFpsHint() { const hintEl = document.getElementById('settings-baud-fps-hint'); const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); - _renderFpsHint(hintEl, baudRate, ledCount); + _renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type); } function _renderDiscoveryList() { const selectedType = document.getElementById('device-type').value; const devices = _discoveryCache[selectedType]; - // Adalight: populate serial port dropdown instead of discovery list - if (selectedType === 'adalight') { + // Serial devices: populate serial port dropdown instead of discovery list + if (isSerialDevice(selectedType)) { _populateSerialPortDropdown(devices || []); return; } @@ -1190,8 +1198,9 @@ function _populateSerialPortDropdown(devices) { function onSerialPortFocus() { // Lazy-load: trigger discovery when user opens the serial port dropdown - if (!('adalight' in _discoveryCache)) { - scanForDevices('adalight'); + const deviceType = document.getElementById('device-type')?.value || 'adalight'; + if (!(deviceType in _discoveryCache)) { + scanForDevices(deviceType); } } @@ -1205,7 +1214,8 @@ async function _populateSettingsSerialPorts(currentUrl) { select.appendChild(loadingOpt); try { - const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, { + const discoverType = settingsInitialValues.device_type || 'adalight'; + const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, { headers: getHeaders() }); if (!resp.ok) return; @@ -1279,7 +1289,7 @@ async function scanForDevices(forceType) { const section = document.getElementById('discovery-section'); const scanBtn = document.getElementById('scan-network-btn'); - if (scanType === 'adalight') { + if (isSerialDevice(scanType)) { // Show loading in the serial port dropdown const select = document.getElementById('device-serial-port'); select.innerHTML = ''; @@ -1308,7 +1318,7 @@ async function scanForDevices(forceType) { if (scanBtn) scanBtn.disabled = false; if (!response.ok) { - if (scanType !== 'adalight') { + if (!isSerialDevice(scanType)) { empty.style.display = 'block'; empty.querySelector('small').textContent = t('device.scan.error'); } @@ -1326,7 +1336,7 @@ async function scanForDevices(forceType) { } catch (err) { loading.style.display = 'none'; if (scanBtn) scanBtn.disabled = false; - if (scanType !== 'adalight') { + if (!isSerialDevice(scanType)) { empty.style.display = 'block'; empty.querySelector('small').textContent = t('device.scan.error'); } @@ -1343,7 +1353,7 @@ function selectDiscoveredDevice(device) { const typeSelect = document.getElementById('device-type'); if (typeSelect) typeSelect.value = device.device_type; onDeviceTypeChanged(); - if (device.device_type === 'adalight') { + if (isSerialDevice(device.device_type)) { document.getElementById('device-serial-port').value = device.url; } else { document.getElementById('device-url').value = device.url; @@ -1356,7 +1366,7 @@ async function handleAddDevice(event) { const name = document.getElementById('device-name').value.trim(); const deviceType = document.getElementById('device-type')?.value || 'wled'; - const url = deviceType === 'adalight' + const url = isSerialDevice(deviceType) ? document.getElementById('device-serial-port').value : document.getElementById('device-url').value.trim(); const error = document.getElementById('add-device-error'); @@ -1374,7 +1384,7 @@ async function handleAddDevice(event) { body.led_count = parseInt(ledCountInput.value, 10); } const baudRateSelect = document.getElementById('device-baud-rate'); - if (deviceType === 'adalight' && baudRateSelect && baudRateSelect.value) { + if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) { body.baud_rate = parseInt(baudRateSelect.value, 10); } const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); @@ -1456,13 +1466,19 @@ async function loadDashboard() { if (!container) { _dashboardLoading = false; return; } try { - const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + // Fetch targets and profiles in parallel + const [targetsResp, profilesResp] = await Promise.all([ + fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }), + fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null), + ]); if (targetsResp.status === 401) { handle401Error(); return; } const targetsData = await targetsResp.json(); const targets = targetsData.targets || []; + const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] }; + const profiles = profilesData.profiles || []; - if (targets.length === 0) { + if (targets.length === 0 && profiles.length === 0) { container.innerHTML = `
${t('dashboard.no_targets')}
`; return; } @@ -1487,6 +1503,21 @@ async function loadDashboard() { let html = ''; + // Profiles section + if (profiles.length > 0) { + const activeProfiles = profiles.filter(p => p.is_active); + const inactiveProfiles = profiles.filter(p => !p.is_active); + + html += `
+
+ ${t('dashboard.section.profiles')} + ${profiles.length} +
+ ${activeProfiles.map(p => renderDashboardProfile(p)).join('')} + ${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')} +
`; + } + // Running section if (running.length > 0) { html += `
@@ -1524,9 +1555,10 @@ function renderDashboardTarget(target, isRunning) { const state = target.state || {}; const metrics = target.metrics || {}; const isLed = target.target_type === 'led' || target.target_type === 'wled'; - const icon = isLed ? '💡' : '🎨'; + const icon = '⚡'; + const typeLabel = isLed ? 'LED' : 'Key Colors'; - let subtitleParts = []; + let subtitleParts = [typeLabel]; if (isLed && state.device_name) { subtitleParts.push(state.device_name); } @@ -1566,8 +1598,7 @@ function renderDashboardTarget(target, isRunning) {
- - +
`; } else { @@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
${escapeHtml(target.name)}
${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''} - ${t('dashboard.section.stopped')}
- +
`; } } +function renderDashboardProfile(profile) { + const isActive = profile.is_active; + const isDisabled = !profile.enabled; + + // Condition summary + let condSummary = ''; + if (profile.conditions.length > 0) { + const parts = profile.conditions.map(c => { + if (c.condition_type === 'application') { + const apps = (c.apps || []).join(', '); + const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); + return `${apps} (${matchLabel})`; + } + return c.condition_type; + }); + const logic = profile.condition_logic === 'and' ? ' & ' : ' | '; + condSummary = parts.join(logic); + } + + const statusBadge = isDisabled + ? `${t('profiles.status.disabled')}` + : isActive + ? `${t('profiles.status.active')}` + : `${t('profiles.status.inactive')}`; + + const targetCount = profile.target_ids.length; + const activeCount = (profile.active_target_ids || []).length; + const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; + + return `
+
+ 📋 +
+
${escapeHtml(profile.name)}
+ ${condSummary ? `
${escapeHtml(condSummary)}
` : ''} +
+ ${statusBadge} +
+
+
+
${targetsInfo}
+
${t('dashboard.targets')}
+
+
+
+ +
+
`; +} + +async function dashboardToggleProfile(profileId, enable) { + try { + const endpoint = enable ? 'enable' : 'disable'; + const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + loadDashboard(); + } + } catch (error) { + showToast('Failed to toggle profile', 'error'); + } +} + async function dashboardStartTarget(targetId) { try { const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { @@ -1651,7 +1749,7 @@ function startDashboardWS() { _dashboardWS.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.type === 'state_change') { + if (data.type === 'state_change' || data.type === 'profile_state_changed') { loadDashboard(); } } catch {} @@ -2642,6 +2740,12 @@ document.addEventListener('click', (e) => { closeAddDeviceModal(); return; } + + // Profile editor modal: close on backdrop + if (modalId === 'profile-editor-modal') { + closeProfileEditorModal(); + return; + } }); // Cleanup on page unload @@ -6539,3 +6643,392 @@ async function capturePatternBackground() { showToast('Failed to capture background', 'error'); } } + +// ===================================================================== +// PROFILES +// ===================================================================== + +let _profilesCache = null; + +async function loadProfiles() { + const container = document.getElementById('profiles-content'); + if (!container) return; + + try { + const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load profiles'); + const data = await resp.json(); + _profilesCache = data.profiles; + renderProfiles(data.profiles); + } catch (error) { + console.error('Failed to load profiles:', error); + container.innerHTML = `

${error.message}

`; + } +} + +function renderProfiles(profiles) { + const container = document.getElementById('profiles-content'); + + let html = '
'; + for (const p of profiles) { + html += createProfileCard(p); + } + html += `
+
+
+
`; + html += '
'; + + container.innerHTML = html; + updateAllText(); +} + +function createProfileCard(profile) { + const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive'; + const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive'); + + // Condition summary as pills + let condPills = ''; + if (profile.conditions.length === 0) { + condPills = `${t('profiles.conditions.empty')}`; + } else { + const parts = profile.conditions.map(c => { + if (c.condition_type === 'application') { + const apps = (c.apps || []).join(', '); + const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); + return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`; + } + return `${c.condition_type}`; + }); + const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR '; + condPills = parts.join(`${logicLabel}`); + } + + // Target count pill + const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`; + + // Last activation timestamp + let lastActivityMeta = ''; + if (profile.last_activated_at) { + const ts = new Date(profile.last_activated_at); + lastActivityMeta = `🕐 ${ts.toLocaleString()}`; + } + + return ` +
+
+ +
+
+
+ ${escapeHtml(profile.name)} + ${statusText} +
+
+
+ ${profile.condition_logic === 'and' ? 'ALL' : 'ANY'} + ⚡ ${targetCountText} + ${lastActivityMeta} +
+
${condPills}
+
+ + +
+
`; +} + +async function openProfileEditor(profileId) { + const modal = document.getElementById('profile-editor-modal'); + const titleEl = document.getElementById('profile-editor-title'); + const idInput = document.getElementById('profile-editor-id'); + const nameInput = document.getElementById('profile-editor-name'); + const enabledInput = document.getElementById('profile-editor-enabled'); + const logicSelect = document.getElementById('profile-editor-logic'); + const condList = document.getElementById('profile-conditions-list'); + const errorEl = document.getElementById('profile-editor-error'); + + errorEl.style.display = 'none'; + condList.innerHTML = ''; + + // Load available targets for the checklist + await loadProfileTargetChecklist([]); + + if (profileId) { + // Edit mode + titleEl.textContent = t('profiles.edit'); + try { + const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load profile'); + const profile = await resp.json(); + + idInput.value = profile.id; + nameInput.value = profile.name; + enabledInput.checked = profile.enabled; + logicSelect.value = profile.condition_logic; + + // Populate conditions + for (const c of profile.conditions) { + addProfileConditionRow(c); + } + + // Populate target checklist + await loadProfileTargetChecklist(profile.target_ids); + } catch (e) { + showToast(e.message, 'error'); + return; + } + } else { + // Create mode + titleEl.textContent = t('profiles.add'); + idInput.value = ''; + nameInput.value = ''; + enabledInput.checked = true; + logicSelect.value = 'or'; + } + + modal.style.display = 'flex'; + lockBody(); + updateAllText(); +} + +function closeProfileEditorModal() { + document.getElementById('profile-editor-modal').style.display = 'none'; + unlockBody(); +} + +async function loadProfileTargetChecklist(selectedIds) { + const container = document.getElementById('profile-targets-list'); + try { + const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load targets'); + const data = await resp.json(); + const targets = data.targets || []; + + if (targets.length === 0) { + container.innerHTML = `${t('profiles.targets.empty')}`; + return; + } + + container.innerHTML = targets.map(tgt => { + const checked = selectedIds.includes(tgt.id) ? 'checked' : ''; + return ``; + }).join(''); + } catch (e) { + container.innerHTML = `${e.message}`; + } +} + +function addProfileCondition() { + addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); +} + +function addProfileConditionRow(condition) { + const list = document.getElementById('profile-conditions-list'); + const row = document.createElement('div'); + row.className = 'profile-condition-row'; + + const appsValue = (condition.apps || []).join('\n'); + const matchType = condition.match_type || 'running'; + + row.innerHTML = ` +
+ ${t('profiles.condition.application')} + +
+
+
+ + +
+
+
+ + +
+ + +
+
+ `; + + // Wire up browse button + const browseBtn = row.querySelector('.btn-browse-apps'); + const picker = row.querySelector('.process-picker'); + browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); + + // Wire up search filter + const searchInput = row.querySelector('.process-picker-search'); + searchInput.addEventListener('input', () => filterProcessPicker(picker)); + + list.appendChild(row); +} + +async function toggleProcessPicker(picker, row) { + if (picker.style.display !== 'none') { + picker.style.display = 'none'; + return; + } + + const listEl = picker.querySelector('.process-picker-list'); + const searchEl = picker.querySelector('.process-picker-search'); + searchEl.value = ''; + listEl.innerHTML = `
${t('common.loading')}
`; + picker.style.display = ''; + + try { + const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() }); + if (resp.status === 401) { handle401Error(); return; } + if (!resp.ok) throw new Error('Failed to fetch processes'); + const data = await resp.json(); + + // Get already-added apps to mark them + const textarea = row.querySelector('.condition-apps'); + const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)); + + picker._processes = data.processes; + picker._existing = existing; + renderProcessPicker(picker, data.processes, existing); + searchEl.focus(); + } catch (e) { + listEl.innerHTML = `
${e.message}
`; + } +} + +function renderProcessPicker(picker, processes, existing) { + const listEl = picker.querySelector('.process-picker-list'); + if (processes.length === 0) { + listEl.innerHTML = `
${t('profiles.condition.application.no_processes')}
`; + return; + } + listEl.innerHTML = processes.map(p => { + const added = existing.has(p.toLowerCase()); + return `
${escapeHtml(p)}${added ? ' ✓' : ''}
`; + }).join(''); + + // Click handler for each item + listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { + item.addEventListener('click', () => { + const proc = item.dataset.process; + const row = picker.closest('.profile-condition-row'); + const textarea = row.querySelector('.condition-apps'); + const current = textarea.value.trim(); + textarea.value = current ? current + '\n' + proc : proc; + item.classList.add('added'); + item.textContent = proc + ' ✓'; + // Update existing set + picker._existing.add(proc.toLowerCase()); + }); + }); +} + +function filterProcessPicker(picker) { + const query = picker.querySelector('.process-picker-search').value.toLowerCase(); + const filtered = (picker._processes || []).filter(p => p.includes(query)); + renderProcessPicker(picker, filtered, picker._existing || new Set()); +} + +function getProfileEditorConditions() { + const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row'); + const conditions = []; + rows.forEach(row => { + const matchType = row.querySelector('.condition-match-type').value; + const appsText = row.querySelector('.condition-apps').value.trim(); + const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; + conditions.push({ condition_type: 'application', apps, match_type: matchType }); + }); + return conditions; +} + +function getProfileEditorTargetIds() { + const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked'); + return Array.from(checkboxes).map(cb => cb.value); +} + +async function saveProfileEditor() { + const idInput = document.getElementById('profile-editor-id'); + const nameInput = document.getElementById('profile-editor-name'); + const enabledInput = document.getElementById('profile-editor-enabled'); + const logicSelect = document.getElementById('profile-editor-logic'); + const errorEl = document.getElementById('profile-editor-error'); + + const name = nameInput.value.trim(); + if (!name) { + errorEl.textContent = 'Name is required'; + errorEl.style.display = 'block'; + return; + } + + const body = { + name, + enabled: enabledInput.checked, + condition_logic: logicSelect.value, + conditions: getProfileEditorConditions(), + target_ids: getProfileEditorTargetIds(), + }; + + const profileId = idInput.value; + const isEdit = !!profileId; + + try { + const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`; + const resp = await fetch(url, { + method: isEdit ? 'PUT' : 'POST', + headers: getHeaders(), + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Failed to save profile'); + } + + closeProfileEditorModal(); + showToast(isEdit ? 'Profile updated' : 'Profile created', 'success'); + loadProfiles(); + } catch (e) { + errorEl.textContent = e.message; + errorEl.style.display = 'block'; + } +} + +async function toggleProfileEnabled(profileId, enable) { + try { + const action = enable ? 'enable' : 'disable'; + const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, { + method: 'POST', + headers: getHeaders(), + }); + if (!resp.ok) throw new Error(`Failed to ${action} profile`); + loadProfiles(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function deleteProfile(profileId, profileName) { + const msg = t('profiles.delete.confirm').replace('{name}', profileName); + const confirmed = await showConfirm(msg); + if (!confirmed) return; + + try { + const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { + method: 'DELETE', + headers: getHeaders(), + }); + if (!resp.ok) throw new Error('Failed to delete profile'); + showToast('Profile deleted', 'success'); + loadProfiles(); + } catch (e) { + showToast(e.message, 'error'); + } +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index a309c37..05ab0fc 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -36,6 +36,7 @@
+
@@ -46,6 +47,12 @@
+
+
+
+
+
+
@@ -650,6 +657,7 @@
@@ -982,6 +990,81 @@
+ + +
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b1b51eb..defefb9 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -464,5 +464,42 @@ "dashboard.errors": "Errors", "dashboard.device": "Device", "dashboard.stop_all": "Stop All", - "dashboard.failed": "Failed to load dashboard" + "dashboard.failed": "Failed to load dashboard", + "dashboard.section.profiles": "Profiles", + "dashboard.targets": "Targets", + + "profiles.title": "\uD83D\uDCCB Profiles", + "profiles.empty": "No profiles configured. Create one to automate target activation.", + "profiles.add": "\uD83D\uDCCB Add Profile", + "profiles.edit": "Edit Profile", + "profiles.delete.confirm": "Delete profile \"{name}\"?", + "profiles.name": "Name:", + "profiles.name.hint": "A descriptive name for this profile", + "profiles.enabled": "Enabled:", + "profiles.enabled.hint": "Disabled profiles won't activate even when conditions are met", + "profiles.condition_logic": "Condition Logic:", + "profiles.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)", + "profiles.condition_logic.or": "Any condition (OR)", + "profiles.condition_logic.and": "All conditions (AND)", + "profiles.conditions": "Conditions:", + "profiles.conditions.hint": "Rules that determine when this profile activates", + "profiles.conditions.add": "Add Condition", + "profiles.conditions.empty": "No conditions \u2014 profile will never activate automatically", + "profiles.condition.application": "Application", + "profiles.condition.application.apps": "Applications:", + "profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", + "profiles.condition.application.browse": "Browse", + "profiles.condition.application.search": "Filter processes...", + "profiles.condition.application.no_processes": "No processes found", + "profiles.condition.application.match_type": "Match Type:", + "profiles.condition.application.match_type.hint": "How to detect the application", + "profiles.condition.application.match_type.running": "Running", + "profiles.condition.application.match_type.topmost": "Topmost (foreground)", + "profiles.targets": "Targets:", + "profiles.targets.hint": "Targets to start when this profile activates", + "profiles.targets.empty": "No targets available", + "profiles.status.active": "Active", + "profiles.status.inactive": "Inactive", + "profiles.status.disabled": "Disabled", + "profiles.last_activated": "Last activated" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 9fe9e3c..5f53c64 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -464,5 +464,42 @@ "dashboard.errors": "Ошибки", "dashboard.device": "Устройство", "dashboard.stop_all": "Остановить все", - "dashboard.failed": "Не удалось загрузить обзор" + "dashboard.failed": "Не удалось загрузить обзор", + "dashboard.section.profiles": "Профили", + "dashboard.targets": "Цели", + + "profiles.title": "\uD83D\uDCCB Профили", + "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", + "profiles.add": "\uD83D\uDCCB Добавить профиль", + "profiles.edit": "Редактировать профиль", + "profiles.delete.confirm": "Удалить профиль \"{name}\"?", + "profiles.name": "Название:", + "profiles.name.hint": "Описательное имя для профиля", + "profiles.enabled": "Включён:", + "profiles.enabled.hint": "Отключённые профили не активируются даже при выполнении условий", + "profiles.condition_logic": "Логика условий:", + "profiles.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)", + "profiles.condition_logic.or": "Любое условие (ИЛИ)", + "profiles.condition_logic.and": "Все условия (И)", + "profiles.conditions": "Условия:", + "profiles.conditions.hint": "Правила, определяющие когда профиль активируется", + "profiles.conditions.add": "Добавить условие", + "profiles.conditions.empty": "Нет условий \u2014 профиль не активируется автоматически", + "profiles.condition.application": "Приложение", + "profiles.condition.application.apps": "Приложения:", + "profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", + "profiles.condition.application.browse": "Обзор", + "profiles.condition.application.search": "Фильтр процессов...", + "profiles.condition.application.no_processes": "Процессы не найдены", + "profiles.condition.application.match_type": "Тип соответствия:", + "profiles.condition.application.match_type.hint": "Как определять наличие приложения", + "profiles.condition.application.match_type.running": "Запущено", + "profiles.condition.application.match_type.topmost": "На переднем плане", + "profiles.targets": "Цели:", + "profiles.targets.hint": "Цели для запуска при активации профиля", + "profiles.targets.empty": "Нет доступных целей", + "profiles.status.active": "Активен", + "profiles.status.inactive": "Неактивен", + "profiles.status.disabled": "Отключён", + "profiles.last_activated": "Последняя активация" } diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 6d22092..15e41a7 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -3225,32 +3225,34 @@ input:-webkit-autofill:focus { /* ── Dashboard ── */ .dashboard-section { - margin-bottom: 24px; + margin-bottom: 16px; } .dashboard-section-header { - font-size: 1rem; + font-size: 0.8rem; font-weight: 600; - margin-bottom: 10px; + margin-bottom: 6px; color: var(--text-secondary); display: flex; align-items: center; - gap: 8px; + gap: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; } .dashboard-section-count { background: var(--border-color); color: var(--text-secondary); border-radius: 10px; - padding: 1px 8px; - font-size: 0.8rem; + padding: 0 6px; + font-size: 0.75rem; font-weight: 600; } .dashboard-stop-all { margin-left: auto; - padding: 3px 10px; - font-size: 0.75rem; + padding: 2px 8px; + font-size: 0.7rem; white-space: nowrap; flex: 0 0 auto; } @@ -3259,35 +3261,36 @@ input:-webkit-autofill:focus { display: grid; grid-template-columns: 1fr auto auto; align-items: center; - gap: 16px; - padding: 12px 16px; + gap: 12px; + padding: 8px 12px; background: var(--card-bg); border: 1px solid var(--border-color); - border-radius: 8px; - margin-bottom: 8px; + border-radius: 6px; + margin-bottom: 4px; } .dashboard-target-info { display: flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; overflow: hidden; } .dashboard-target-icon { - font-size: 1.2rem; + font-size: 1rem; flex-shrink: 0; } .dashboard-target-name { + font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; - gap: 6px; + gap: 4px; } .dashboard-target-name .health-dot { @@ -3296,7 +3299,7 @@ input:-webkit-autofill:focus { } .dashboard-target-subtitle { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; @@ -3304,24 +3307,25 @@ input:-webkit-autofill:focus { } .dashboard-target-metrics { - display: grid; - grid-template-columns: 90px 80px 60px; + display: flex; gap: 12px; align-items: center; } .dashboard-metric { text-align: center; + min-width: 48px; } .dashboard-metric-value { - font-size: 1.05rem; + font-size: 0.85rem; font-weight: 700; color: var(--primary-color); + line-height: 1.2; } .dashboard-metric-label { - font-size: 0.7rem; + font-size: 0.6rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.3px; @@ -3330,11 +3334,11 @@ input:-webkit-autofill:focus { .dashboard-target-actions { display: flex; align-items: center; - gap: 8px; + gap: 4px; } .dashboard-status-dot { - font-size: 1.2rem; + font-size: 1rem; line-height: 1; } @@ -3345,24 +3349,39 @@ input:-webkit-autofill:focus { .dashboard-no-targets { text-align: center; - padding: 40px 20px; + padding: 32px 16px; color: var(--text-secondary); - font-size: 1rem; + font-size: 0.9rem; } .dashboard-badge-stopped { - padding: 3px 10px; + padding: 2px 8px; border-radius: 10px; - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 600; background: var(--border-color); color: var(--text-secondary); + flex-shrink: 0; +} + +.dashboard-badge-active { + padding: 2px 8px; + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; + background: var(--success-color, #28a745); + color: #fff; + flex-shrink: 0; +} + +.dashboard-profile .dashboard-target-metrics { + min-width: 48px; } @media (max-width: 768px) { .dashboard-target { grid-template-columns: 1fr; - gap: 10px; + gap: 6px; } .dashboard-target-actions { @@ -3370,3 +3389,189 @@ input:-webkit-autofill:focus { } } +/* ===== PROFILES ===== */ + +.badge-profile-active { + background: var(--success-color, #28a745); + color: #fff; +} + +.badge-profile-inactive { + background: var(--border-color); + color: var(--text-color); +} + +.badge-profile-disabled { + background: var(--border-color); + color: var(--text-muted); + opacity: 0.7; +} + +.profile-status-disabled { + opacity: 0.6; +} + +.profile-logic-label { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-muted); + padding: 0 4px; +} + +/* Profile condition editor rows */ +.profile-condition-row { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + margin-bottom: 8px; + background: var(--bg-secondary, var(--bg-color)); +} + +.condition-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.condition-type-label { + font-weight: 600; + font-size: 0.9rem; +} + +.btn-remove-condition { + background: none; + border: none; + color: var(--danger-color, #dc3545); + cursor: pointer; + font-size: 1rem; + padding: 2px 6px; +} + +.condition-fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.condition-field label { + display: block; + font-size: 0.85rem; + margin-bottom: 3px; + color: var(--text-muted); +} + +.condition-field select, +.condition-field textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9rem; + font-family: inherit; +} + +.condition-apps { + resize: vertical; + min-height: 60px; +} + +.condition-apps-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.btn-browse-apps { + background: none; + border: 1px solid var(--border-color); + color: var(--text-color); + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.btn-browse-apps:hover { + border-color: var(--primary-color); + background: rgba(33, 150, 243, 0.1); +} + +.process-picker { + margin-top: 6px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.process-picker-search { + width: 100%; + padding: 6px 8px; + border: none; + border-bottom: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85rem; + font-family: inherit; + outline: none; + box-sizing: border-box; +} + +.process-picker-list { + max-height: 160px; + overflow-y: auto; +} + +.process-picker-item { + padding: 4px 8px; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.1s; +} + +.process-picker-item:hover { + background: rgba(33, 150, 243, 0.15); +} + +.process-picker-item.added { + color: var(--text-muted); + cursor: default; + opacity: 0.6; +} + +.process-picker-loading { + padding: 8px; + font-size: 0.8rem; + color: var(--text-muted); + text-align: center; +} + +/* Profile target checklist */ +.profile-targets-checklist { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px; +} + +.profile-target-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + cursor: pointer; + border-radius: 3px; +} + +.profile-target-item:hover { + background: var(--bg-secondary, var(--bg-color)); +} + +.profile-target-item input[type="checkbox"] { + margin: 0; +} +