Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
378
server/src/wled_controller/static/js/features/profiles.js
Normal file
378
server/src/wled_controller/static/js/features/profiles.js
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Profiles — profile cards, editor, condition builder, process picker.
|
||||
*/
|
||||
|
||||
import { _profilesCache, set_profilesCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, escapeHtml, handle401Error } from '../core/api.js';
|
||||
import { t, updateAllText } from '../core/i18n.js';
|
||||
import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js';
|
||||
|
||||
export 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();
|
||||
set_profilesCache(data.profiles);
|
||||
renderProfiles(data.profiles);
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles:', error);
|
||||
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfiles(profiles) {
|
||||
const container = document.getElementById('profiles-content');
|
||||
|
||||
let html = '<div class="devices-grid">';
|
||||
for (const p of profiles) {
|
||||
html += createProfileCard(p);
|
||||
}
|
||||
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>`;
|
||||
html += '</div>';
|
||||
|
||||
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');
|
||||
|
||||
let condPills = '';
|
||||
if (profile.conditions.length === 0) {
|
||||
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
|
||||
} 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 `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||
}
|
||||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||||
});
|
||||
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
|
||||
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
|
||||
}
|
||||
|
||||
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
|
||||
|
||||
let lastActivityMeta = '';
|
||||
if (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>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
|
||||
<div class="card-top-actions">
|
||||
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">✕</button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
${escapeHtml(profile.name)}
|
||||
<span class="badge badge-profile-${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta">${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}</span>
|
||||
<span class="card-meta">⚡ ${targetCountText}</span>
|
||||
${lastActivityMeta}
|
||||
</div>
|
||||
<div class="stream-card-props">${condPills}</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">⚙️</button>
|
||||
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
|
||||
${profile.enabled ? '⏸' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export 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 = '';
|
||||
|
||||
await loadProfileTargetChecklist([]);
|
||||
|
||||
if (profileId) {
|
||||
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;
|
||||
|
||||
for (const c of profile.conditions) {
|
||||
addProfileConditionRow(c);
|
||||
}
|
||||
|
||||
await loadProfileTargetChecklist(profile.target_ids);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
titleEl.textContent = t('profiles.add');
|
||||
idInput.value = '';
|
||||
nameInput.value = '';
|
||||
enabledInput.checked = true;
|
||||
logicSelect.value = 'or';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
updateAllText();
|
||||
}
|
||||
|
||||
export 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 = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = targets.map(tgt => {
|
||||
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
|
||||
return `<label class="profile-target-item">
|
||||
<input type="checkbox" value="${tgt.id}" ${checked}>
|
||||
<span>${escapeHtml(tgt.name)}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
|
||||
}
|
||||
}
|
||||
|
||||
export 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 = `
|
||||
<div class="condition-header">
|
||||
<span class="condition-type-label">${t('profiles.condition.application')}</span>
|
||||
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
||||
</div>
|
||||
<div class="condition-fields">
|
||||
<div class="condition-field">
|
||||
<label data-i18n="profiles.condition.application.match_type">${t('profiles.condition.application.match_type')}</label>
|
||||
<select class="condition-match-type">
|
||||
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
|
||||
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<div class="condition-apps-header">
|
||||
<label data-i18n="profiles.condition.application.apps">${t('profiles.condition.application.apps')}</label>
|
||||
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
|
||||
</div>
|
||||
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||
<div class="process-picker" style="display:none">
|
||||
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
|
||||
<div class="process-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const browseBtn = row.querySelector('.btn-browse-apps');
|
||||
const picker = row.querySelector('.process-picker');
|
||||
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
|
||||
|
||||
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 = `<div class="process-picker-loading">${t('common.loading')}</div>`;
|
||||
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();
|
||||
|
||||
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 = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProcessPicker(picker, processes, existing) {
|
||||
const listEl = picker.querySelector('.process-picker-list');
|
||||
if (processes.length === 0) {
|
||||
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = processes.map(p => {
|
||||
const added = existing.has(p.toLowerCase());
|
||||
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ✓' : ''}</div>`;
|
||||
}).join('');
|
||||
|
||||
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 + ' ✓';
|
||||
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);
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user