2e747b5ece
Feature 1 — Profile Conditions: time-of-day, system idle (Win32 GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE) condition types for automatic profile activation. Feature 2 — Scene Presets: snapshot/restore system that captures target running states, device brightness, and profile enables. Server-side capture with 5-step activation order. Dedicated Scenes tab with CardSection-based card grid, command palette integration, and dashboard quick-activate section. Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt, MQTTLEDClient device provider for pixel output, MQTT profile condition type with topic/payload matching, and frontend support for MQTT device type and condition editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
613 lines
29 KiB
JavaScript
613 lines
29 KiB
JavaScript
/**
|
||
* Profiles — profile cards, editor, condition builder, process picker.
|
||
*/
|
||
|
||
import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js';
|
||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { CardSection } from '../core/card-sections.js';
|
||
import { updateTabBadge } from './tabs.js';
|
||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js';
|
||
|
||
class ProfileEditorModal extends Modal {
|
||
constructor() { super('profile-editor-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('profile-editor-name').value,
|
||
enabled: document.getElementById('profile-editor-enabled').checked.toString(),
|
||
logic: document.getElementById('profile-editor-logic').value,
|
||
conditions: JSON.stringify(getProfileEditorConditions()),
|
||
targets: JSON.stringify(getProfileEditorTargetIds()),
|
||
};
|
||
}
|
||
}
|
||
|
||
const profileModal = new ProfileEditorModal();
|
||
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' });
|
||
|
||
// Re-render profiles when language changes (only if tab is active)
|
||
document.addEventListener('languageChanged', () => {
|
||
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles();
|
||
});
|
||
|
||
// React to real-time profile state changes from global events WS
|
||
document.addEventListener('server:profile_state_changed', () => {
|
||
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') {
|
||
loadProfiles();
|
||
}
|
||
});
|
||
|
||
export async function loadProfiles() {
|
||
if (_profilesLoading) return;
|
||
set_profilesLoading(true);
|
||
const container = document.getElementById('profiles-content');
|
||
if (!container) { set_profilesLoading(false); return; }
|
||
setTabRefreshing('profiles-content', true);
|
||
|
||
try {
|
||
const [profilesResp, targetsResp] = await Promise.all([
|
||
fetchWithAuth('/profiles'),
|
||
fetchWithAuth('/picture-targets'),
|
||
]);
|
||
if (!profilesResp.ok) throw new Error('Failed to load profiles');
|
||
const data = await profilesResp.json();
|
||
const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] };
|
||
const allTargets = targetsData.targets || [];
|
||
// Batch fetch all target states in a single request
|
||
const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states');
|
||
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
|
||
const runningTargetIds = new Set(
|
||
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
|
||
);
|
||
set_profilesCache(data.profiles);
|
||
const activeCount = data.profiles.filter(p => p.is_active).length;
|
||
updateTabBadge('profiles', activeCount);
|
||
renderProfiles(data.profiles, runningTargetIds);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to load profiles:', error);
|
||
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
||
} finally {
|
||
set_profilesLoading(false);
|
||
setTabRefreshing('profiles-content', false);
|
||
}
|
||
}
|
||
|
||
export function expandAllProfileSections() {
|
||
CardSection.expandAll([csProfiles]);
|
||
}
|
||
|
||
export function collapseAllProfileSections() {
|
||
CardSection.collapseAll([csProfiles]);
|
||
}
|
||
|
||
function renderProfiles(profiles, runningTargetIds = new Set()) {
|
||
const container = document.getElementById('profiles-content');
|
||
|
||
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
|
||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
||
container.innerHTML = toolbar + csProfiles.render(items);
|
||
csProfiles.bind();
|
||
|
||
// Localize data-i18n elements within the profiles container only
|
||
// (calling global updateAllText() would trigger loadProfiles() again → infinite loop)
|
||
container.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.getAttribute('data-i18n'));
|
||
});
|
||
}
|
||
|
||
function createProfileCard(profile, runningTargetIds = new Set()) {
|
||
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 === 'always') {
|
||
return `<span class="stream-card-prop">${ICON_OK} ${t('profiles.condition.always')}</span>`;
|
||
}
|
||
if (c.condition_type === 'application') {
|
||
const apps = (c.apps || []).join(', ');
|
||
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
|
||
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||
}
|
||
if (c.condition_type === 'time_of_day') {
|
||
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||
}
|
||
if (c.condition_type === 'system_idle') {
|
||
const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active');
|
||
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||
}
|
||
if (c.condition_type === 'display_state') {
|
||
const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on'));
|
||
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}</span>`;
|
||
}
|
||
if (c.condition_type === 'mqtt') {
|
||
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
|
||
}
|
||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||
});
|
||
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.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')}">${ICON_CLOCK} ${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' ? t('profiles.logic.all') : t('profiles.logic.any')}</span>
|
||
<span class="card-meta">${ICON_TARGET} ${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')}">${ICON_SETTINGS}</button>
|
||
${profile.target_ids.length > 0 ? (() => {
|
||
const anyRunning = profile.target_ids.some(id => runningTargetIds.has(id));
|
||
return `<button class="btn btn-icon ${anyRunning ? 'btn-warning' : 'btn-success'}"
|
||
onclick="toggleProfileTargets('${profile.id}')"
|
||
title="${anyRunning ? t('profiles.toggle_all.stop') : t('profiles.toggle_all.start')}">
|
||
${anyRunning ? ICON_STOP_PLAIN : ICON_START}
|
||
</button>`;
|
||
})() : ''}
|
||
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
|
||
${profile.enabled ? ICON_PAUSE : ICON_START}
|
||
</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.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`;
|
||
try {
|
||
const resp = await fetchWithAuth(`/profiles/${profileId}`);
|
||
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.innerHTML = `${ICON_PROFILE} ${t('profiles.add')}`;
|
||
idInput.value = '';
|
||
nameInput.value = '';
|
||
enabledInput.checked = true;
|
||
logicSelect.value = 'or';
|
||
}
|
||
|
||
profileModal.open();
|
||
modal.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.getAttribute('data-i18n'));
|
||
});
|
||
profileModal.snapshot();
|
||
}
|
||
|
||
export async function closeProfileEditorModal() {
|
||
await profileModal.close();
|
||
}
|
||
|
||
async function loadProfileTargetChecklist(selectedIds) {
|
||
const container = document.getElementById('profile-targets-list');
|
||
try {
|
||
const resp = await fetchWithAuth('/picture-targets');
|
||
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 condType = condition.condition_type || 'application';
|
||
|
||
row.innerHTML = `
|
||
<div class="condition-header">
|
||
<select class="condition-type-select">
|
||
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
|
||
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
|
||
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('profiles.condition.time_of_day')}</option>
|
||
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('profiles.condition.system_idle')}</option>
|
||
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('profiles.condition.display_state')}</option>
|
||
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('profiles.condition.mqtt')}</option>
|
||
</select>
|
||
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
||
</div>
|
||
<div class="condition-fields-container"></div>
|
||
`;
|
||
|
||
const typeSelect = row.querySelector('.condition-type-select');
|
||
const container = row.querySelector('.condition-fields-container');
|
||
|
||
function renderFields(type, data) {
|
||
if (type === 'always') {
|
||
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
|
||
return;
|
||
}
|
||
if (type === 'time_of_day') {
|
||
const startTime = data.start_time || '00:00';
|
||
const endTime = data.end_time || '23:59';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.time_of_day.start_time')}</label>
|
||
<input type="time" class="condition-start-time" value="${startTime}">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.time_of_day.end_time')}</label>
|
||
<input type="time" class="condition-end-time" value="${endTime}">
|
||
</div>
|
||
<small class="condition-always-desc">${t('profiles.condition.time_of_day.overnight_hint')}</small>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'system_idle') {
|
||
const idleMinutes = data.idle_minutes ?? 5;
|
||
const whenIdle = data.when_idle ?? true;
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.system_idle.idle_minutes')}</label>
|
||
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.system_idle.mode')}</label>
|
||
<select class="condition-when-idle">
|
||
<option value="true" ${whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_idle')}</option>
|
||
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_active')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'display_state') {
|
||
const dState = data.state || 'on';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.display_state.state')}</label>
|
||
<select class="condition-display-state">
|
||
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('profiles.condition.display_state.on')}</option>
|
||
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('profiles.condition.display_state.off')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'mqtt') {
|
||
const topic = data.topic || '';
|
||
const payload = data.payload || '';
|
||
const matchMode = data.match_mode || 'exact';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.mqtt.topic')}</label>
|
||
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.mqtt.payload')}</label>
|
||
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('profiles.condition.mqtt.match_mode')}</label>
|
||
<select class="condition-mqtt-match-mode">
|
||
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.exact')}</option>
|
||
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.contains')}</option>
|
||
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.regex')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const appsValue = (data.apps || []).join('\n');
|
||
const matchType = data.match_type || 'running';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${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>
|
||
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option>
|
||
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option>
|
||
</select>
|
||
</div>
|
||
<div class="condition-field">
|
||
<div class="condition-apps-header">
|
||
<label>${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 = container.querySelector('.btn-browse-apps');
|
||
const picker = container.querySelector('.process-picker');
|
||
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
|
||
const searchInput = container.querySelector('.process-picker-search');
|
||
searchInput.addEventListener('input', () => filterProcessPicker(picker));
|
||
}
|
||
|
||
renderFields(condType, condition);
|
||
typeSelect.addEventListener('change', () => {
|
||
renderFields(typeSelect.value, {});
|
||
});
|
||
|
||
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 fetchWithAuth('/system/processes');
|
||
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 typeSelect = row.querySelector('.condition-type-select');
|
||
const condType = typeSelect ? typeSelect.value : 'application';
|
||
if (condType === 'always') {
|
||
conditions.push({ condition_type: 'always' });
|
||
} else if (condType === 'time_of_day') {
|
||
conditions.push({
|
||
condition_type: 'time_of_day',
|
||
start_time: row.querySelector('.condition-start-time').value || '00:00',
|
||
end_time: row.querySelector('.condition-end-time').value || '23:59',
|
||
});
|
||
} else if (condType === 'system_idle') {
|
||
conditions.push({
|
||
condition_type: 'system_idle',
|
||
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
|
||
when_idle: row.querySelector('.condition-when-idle').value === 'true',
|
||
});
|
||
} else if (condType === 'display_state') {
|
||
conditions.push({
|
||
condition_type: 'display_state',
|
||
state: row.querySelector('.condition-display-state').value || 'on',
|
||
});
|
||
} else if (condType === 'mqtt') {
|
||
conditions.push({
|
||
condition_type: 'mqtt',
|
||
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
|
||
payload: row.querySelector('.condition-mqtt-payload').value,
|
||
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
|
||
});
|
||
} else {
|
||
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 name = nameInput.value.trim();
|
||
if (!name) {
|
||
profileModal.showError(t('profiles.error.name_required'));
|
||
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 ? `/profiles/${profileId}` : '/profiles';
|
||
const resp = await fetchWithAuth(url, {
|
||
method: isEdit ? 'PUT' : 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.detail || 'Failed to save profile');
|
||
}
|
||
|
||
profileModal.forceClose();
|
||
showToast(isEdit ? t('profiles.updated') : t('profiles.created'), 'success');
|
||
loadProfiles();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
profileModal.showError(e.message);
|
||
}
|
||
}
|
||
|
||
export async function toggleProfileTargets(profileId) {
|
||
try {
|
||
const profileResp = await fetchWithAuth(`/profiles/${profileId}`);
|
||
if (!profileResp.ok) throw new Error('Failed to load profile');
|
||
const profile = await profileResp.json();
|
||
// Batch fetch all target states to determine which are running
|
||
const batchResp = await fetchWithAuth('/picture-targets/batch/states');
|
||
const allStates = batchResp.ok ? (await batchResp.json()).states : {};
|
||
const runningSet = new Set(
|
||
profile.target_ids.filter(id => allStates[id]?.processing)
|
||
);
|
||
const shouldStop = profile.target_ids.some(id => runningSet.has(id));
|
||
await Promise.all(profile.target_ids.map(id =>
|
||
fetchWithAuth(`/picture-targets/${id}/${shouldStop ? 'stop' : 'start'}`, { method: 'POST' }).catch(() => {})
|
||
));
|
||
loadProfiles();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function toggleProfileEnabled(profileId, enable) {
|
||
try {
|
||
const action = enable ? 'enable' : 'disable';
|
||
const resp = await fetchWithAuth(`/profiles/${profileId}/${action}`, {
|
||
method: 'POST',
|
||
});
|
||
if (!resp.ok) throw new Error(`Failed to ${action} profile`);
|
||
loadProfiles();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
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 fetchWithAuth(`/profiles/${profileId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!resp.ok) throw new Error('Failed to delete profile');
|
||
showToast(t('profiles.deleted'), 'success');
|
||
loadProfiles();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|