Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -77,13 +78,12 @@ const _modal = new AdvancedCalibrationModal();
|
||||
|
||||
export async function showAdvancedCalibration(cssId) {
|
||||
try {
|
||||
const [cssResp, psResp] = await Promise.all([
|
||||
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
||||
const [cssSources, psResp] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
]);
|
||||
if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
|
||||
const source = await cssResp.json();
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {};
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
|
||||
@@ -168,6 +168,7 @@ export async function saveAdvancedCalibration() {
|
||||
|
||||
if (resp.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
|
||||
@@ -17,11 +17,18 @@ import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { TagInput } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
let _audioSourceTagsInput = null;
|
||||
|
||||
class AudioSourceModal extends Modal {
|
||||
constructor() { super('audio-source-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('audio-source-name').value,
|
||||
@@ -31,6 +38,7 @@ class AudioSourceModal extends Modal {
|
||||
audioTemplate: document.getElementById('audio-source-audio-template').value,
|
||||
parent: document.getElementById('audio-source-parent').value,
|
||||
channel: document.getElementById('audio-source-channel').value,
|
||||
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,6 +94,11 @@ export async function showAudioSourceModal(sourceType, editData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
_audioSourceTagsInput = new TagInput(document.getElementById('audio-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
||||
|
||||
audioSourceModal.open();
|
||||
audioSourceModal.snapshot();
|
||||
}
|
||||
@@ -115,7 +128,7 @@ export async function saveAudioSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
const payload = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
const deviceVal = document.getElementById('audio-source-device').value || '-1:1';
|
||||
|
||||
@@ -12,14 +12,21 @@ import { updateTabBadge } from './tabs.js';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { csScenes, createSceneCard } from './scene-presets.js';
|
||||
|
||||
let _automationTagsInput = null;
|
||||
|
||||
class AutomationEditorModal extends Modal {
|
||||
constructor() { super('automation-editor-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('automation-editor-name').value,
|
||||
@@ -29,6 +36,7 @@ class AutomationEditorModal extends Modal {
|
||||
scenePresetId: document.getElementById('automation-scene-id').value,
|
||||
deactivationMode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
|
||||
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -204,7 +212,8 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
|
||||
${lastActivityMeta}
|
||||
</div>
|
||||
<div class="stream-card-props">${condPills}</div>`,
|
||||
<div class="stream-card-props">${condPills}</div>
|
||||
${renderTagChips(automation.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
|
||||
@@ -240,6 +249,8 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
|
||||
document.getElementById('automation-fallback-scene-group').style.display = 'none';
|
||||
|
||||
let _editorTags = [];
|
||||
|
||||
if (automationId) {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
|
||||
try {
|
||||
@@ -266,6 +277,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
|
||||
_editorTags = automation.tags || [];
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
@@ -293,6 +305,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||||
idInput.value = '';
|
||||
@@ -314,6 +327,12 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
});
|
||||
|
||||
// Tags
|
||||
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
||||
_automationTagsInput = new TagInput(document.getElementById('automation-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_automationTagsInput.setValue(_editorTags);
|
||||
|
||||
automationModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -671,6 +690,7 @@ export async function saveAutomationEditor() {
|
||||
scene_preset_id: document.getElementById('automation-scene-id').value || null,
|
||||
deactivation_mode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
|
||||
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
const automationId = idInput.value;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -231,13 +232,12 @@ export async function closeCalibrationModal() {
|
||||
|
||||
export async function showCSSCalibration(cssId) {
|
||||
try {
|
||||
const [cssResp, devicesResp] = await Promise.all([
|
||||
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
||||
fetchWithAuth('/devices'),
|
||||
const [cssSources, devices] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
devicesCache.fetch().catch(() => []),
|
||||
]);
|
||||
|
||||
if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const source = await cssResp.json();
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ export async function showCSSCalibration(cssId) {
|
||||
document.getElementById('calibration-css-id').value = cssId;
|
||||
|
||||
// Populate device picker for edge test
|
||||
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device');
|
||||
testDeviceSelect.innerHTML = '';
|
||||
devices.forEach(d => {
|
||||
@@ -940,6 +939,7 @@ export async function saveCalibration() {
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { _cachedSyncClocks, audioSourcesCache, streamsCache } from '../core/state.js';
|
||||
import { _cachedSyncClocks, audioSourcesCache, streamsCache, colorStripSourcesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -16,15 +16,28 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
|
||||
} from './css-gradient-editor.js';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('css-editor-modal');
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
return {
|
||||
@@ -39,7 +52,7 @@ class CSSEditorModal extends Modal {
|
||||
color: document.getElementById('css-editor-color').value,
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: document.getElementById('css-editor-led-count').value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: document.getElementById('css-editor-animation-type').value,
|
||||
cycle_colors: JSON.stringify(_colorCycleColors),
|
||||
effect_type: document.getElementById('css-editor-effect-type').value,
|
||||
@@ -67,12 +80,15 @@ class CSSEditorModal extends Modal {
|
||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||
clock_id: document.getElementById('css-editor-clock').value,
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cssEditorModal = new CSSEditorModal();
|
||||
|
||||
let _cssTagsInput = null;
|
||||
|
||||
// ── EntitySelect instances for CSS editor ──
|
||||
let _cssPictureSourceEntitySelect = null;
|
||||
let _cssAudioSourceEntitySelect = null;
|
||||
@@ -272,13 +288,7 @@ function _gradientStripHTML(pts, w = 80, h = 16) {
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${stops});flex-shrink:0"></span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a gradient preview from _GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||||
*/
|
||||
function _gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
}
|
||||
/* gradientPresetStripHTML imported from css-gradient-editor.js */
|
||||
|
||||
/* ── Effect / audio palette IconSelect instances ─────────────── */
|
||||
|
||||
@@ -355,8 +365,8 @@ function _ensureGradientPresetIconSelect() {
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
||||
...Object.entries(_GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: _gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
})),
|
||||
];
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
@@ -468,16 +478,7 @@ function _loadColorCycleState(css) {
|
||||
}
|
||||
|
||||
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
||||
function rgbArrayToHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||||
function hexToRgbArray(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
/* rgbArrayToHex / hexToRgbArray imported from css-gradient-editor.js */
|
||||
|
||||
/* ── Composite layer helpers ──────────────────────────────────── */
|
||||
|
||||
@@ -1090,7 +1091,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
${propsHtml}
|
||||
</div>`,
|
||||
</div>
|
||||
${renderTagChips(source.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
@@ -1132,8 +1134,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
const sources = await streamsCache.fetch();
|
||||
|
||||
// Fetch all color strip sources for composite layer dropdowns
|
||||
const cssListResp = await fetchWithAuth('/color-strip-sources');
|
||||
const allCssSources = cssListResp.ok ? ((await cssListResp.json()).sources || []) : [];
|
||||
const allCssSources = await colorStripSourcesCache.fetch().catch(() => []);
|
||||
_compositeAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
|
||||
);
|
||||
@@ -1251,9 +1252,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : '';
|
||||
|
||||
if (cssId) {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load color strip source');
|
||||
const css = await resp.json();
|
||||
const cssSources = await colorStripSourcesCache.fetch();
|
||||
const css = cssSources.find(s => s.id === cssId);
|
||||
if (!css) throw new Error('Failed to load color strip source');
|
||||
|
||||
document.getElementById('css-editor-id').value = css.id;
|
||||
document.getElementById('css-editor-name').value = css.name;
|
||||
@@ -1328,6 +1329,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName();
|
||||
|
||||
document.getElementById('css-editor-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||
const _cssTags = cssId
|
||||
? ((await colorStripSourcesCache.fetch()).find(s => s.id === cssId)?.tags || [])
|
||||
: (cloneData ? (cloneData.tags || []) : []);
|
||||
_cssTagsInput = new TagInput(document.getElementById('css-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_cssTagsInput.setValue(_cssTags);
|
||||
|
||||
cssEditorModal.snapshot();
|
||||
cssEditorModal.open();
|
||||
setTimeout(() => document.getElementById('css-editor-name').focus(), 100);
|
||||
@@ -1374,13 +1384,14 @@ export async function saveCSSEditor() {
|
||||
};
|
||||
if (!cssId) payload.source_type = 'color_cycle';
|
||||
} else if (sourceType === 'gradient') {
|
||||
if (_gradientStops.length < 2) {
|
||||
const gStops = getGradientStops();
|
||||
if (gStops.length < 2) {
|
||||
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
name,
|
||||
stops: _gradientStops.map(s => ({
|
||||
stops: gStops.map(s => ({
|
||||
position: s.position,
|
||||
color: s.color,
|
||||
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
||||
@@ -1496,6 +1507,9 @@ export async function saveCSSEditor() {
|
||||
payload.clock_id = clockVal || null;
|
||||
}
|
||||
|
||||
// Tags
|
||||
payload.tags = _cssTagsInput ? _cssTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssId) {
|
||||
@@ -1516,6 +1530,7 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
@@ -1562,9 +1577,9 @@ export function copyEndpointUrl(btn) {
|
||||
|
||||
export async function cloneColorStrip(cssId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load color strip source');
|
||||
const css = await resp.json();
|
||||
const sources = await colorStripSourcesCache.fetch();
|
||||
const css = sources.find(s => s.id === cssId);
|
||||
if (!css) throw new Error('Color strip source not found');
|
||||
showCSSEditor(null, css);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -1585,6 +1600,7 @@ export async function deleteColorStrip(cssId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
@@ -1636,363 +1652,4 @@ export async function stopCSSOverlay(cssId) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
GRADIENT EDITOR
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Internal state: array of stop objects.
|
||||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||
*/
|
||||
let _gradientStops = [];
|
||||
let _gradientSelectedIdx = -1;
|
||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||
|
||||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||
|
||||
function _gradientInterpolate(stops, pos) {
|
||||
if (!stops.length) return [128, 128, 128];
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
if (a.position <= pos && pos <= b.position) {
|
||||
const span = b.position - a.position;
|
||||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||||
const lc = a.colorRight || a.color;
|
||||
const rc = b.color;
|
||||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||||
}
|
||||
}
|
||||
return [128, 128, 128];
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientInit(stops) {
|
||||
_gradientStops = stops.map(s => ({
|
||||
position: parseFloat(s.position ?? 0),
|
||||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||
}));
|
||||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||||
_gradientDragging = null;
|
||||
_gradientSetupTrackClick();
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Presets ──────────────────────────────────────────────────── */
|
||||
|
||||
const _GRADIENT_PRESETS = {
|
||||
rainbow: [
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 0.17, color: [255, 165, 0] },
|
||||
{ position: 0.33, color: [255, 255, 0] },
|
||||
{ position: 0.5, color: [0, 255, 0] },
|
||||
{ position: 0.67, color: [0, 100, 255] },
|
||||
{ position: 0.83, color: [75, 0, 130] },
|
||||
{ position: 1.0, color: [148, 0, 211] },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0.0, color: [255, 60, 0] },
|
||||
{ position: 0.3, color: [255, 120, 20] },
|
||||
{ position: 0.6, color: [200, 40, 80] },
|
||||
{ position: 0.8, color: [120, 20, 120] },
|
||||
{ position: 1.0, color: [40, 10, 60] },
|
||||
],
|
||||
ocean: [
|
||||
{ position: 0.0, color: [0, 10, 40] },
|
||||
{ position: 0.3, color: [0, 60, 120] },
|
||||
{ position: 0.6, color: [0, 140, 180] },
|
||||
{ position: 0.8, color: [100, 220, 240] },
|
||||
{ position: 1.0, color: [200, 240, 255] },
|
||||
],
|
||||
forest: [
|
||||
{ position: 0.0, color: [0, 40, 0] },
|
||||
{ position: 0.3, color: [0, 100, 20] },
|
||||
{ position: 0.6, color: [60, 180, 30] },
|
||||
{ position: 0.8, color: [140, 220, 50] },
|
||||
{ position: 1.0, color: [220, 255, 80] },
|
||||
],
|
||||
fire: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.25, color: [80, 0, 0] },
|
||||
{ position: 0.5, color: [255, 40, 0] },
|
||||
{ position: 0.75, color: [255, 160, 0] },
|
||||
{ position: 1.0, color: [255, 255, 60] },
|
||||
],
|
||||
lava: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.3, color: [120, 0, 0] },
|
||||
{ position: 0.6, color: [255, 60, 0] },
|
||||
{ position: 0.8, color: [255, 160, 40] },
|
||||
{ position: 1.0, color: [255, 255, 120] },
|
||||
],
|
||||
aurora: [
|
||||
{ position: 0.0, color: [0, 20, 40] },
|
||||
{ position: 0.25, color: [0, 200, 100] },
|
||||
{ position: 0.5, color: [0, 100, 200] },
|
||||
{ position: 0.75, color: [120, 0, 200] },
|
||||
{ position: 1.0, color: [0, 200, 140] },
|
||||
],
|
||||
ice: [
|
||||
{ position: 0.0, color: [255, 255, 255] },
|
||||
{ position: 0.3, color: [180, 220, 255] },
|
||||
{ position: 0.6, color: [80, 160, 255] },
|
||||
{ position: 0.85, color: [20, 60, 180] },
|
||||
{ position: 1.0, color: [10, 20, 80] },
|
||||
],
|
||||
warm: [
|
||||
{ position: 0.0, color: [255, 255, 80] },
|
||||
{ position: 0.33, color: [255, 160, 0] },
|
||||
{ position: 0.67, color: [255, 60, 0] },
|
||||
{ position: 1.0, color: [160, 0, 0] },
|
||||
],
|
||||
cool: [
|
||||
{ position: 0.0, color: [0, 255, 200] },
|
||||
{ position: 0.33, color: [0, 120, 255] },
|
||||
{ position: 0.67, color: [60, 0, 255] },
|
||||
{ position: 1.0, color: [120, 0, 180] },
|
||||
],
|
||||
neon: [
|
||||
{ position: 0.0, color: [255, 0, 200] },
|
||||
{ position: 0.25, color: [0, 255, 255] },
|
||||
{ position: 0.5, color: [0, 255, 50] },
|
||||
{ position: 0.75, color: [255, 255, 0] },
|
||||
{ position: 1.0, color: [255, 0, 100] },
|
||||
],
|
||||
pastel: [
|
||||
{ position: 0.0, color: [255, 180, 180] },
|
||||
{ position: 0.2, color: [255, 220, 160] },
|
||||
{ position: 0.4, color: [255, 255, 180] },
|
||||
{ position: 0.6, color: [180, 255, 200] },
|
||||
{ position: 0.8, color: [180, 200, 255] },
|
||||
{ position: 1.0, color: [220, 180, 255] },
|
||||
],
|
||||
};
|
||||
|
||||
export function applyGradientPreset(key) {
|
||||
if (!key || !_GRADIENT_PRESETS[key]) return;
|
||||
gradientInit(_GRADIENT_PRESETS[key]);
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientRenderAll() {
|
||||
_gradientRenderCanvas();
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
}
|
||||
|
||||
function _gradientRenderCanvas() {
|
||||
const canvas = document.getElementById('gradient-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Sync canvas pixel width to its CSS display width
|
||||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const H = canvas.height;
|
||||
const imgData = ctx.createImageData(W, H);
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const pos = W > 1 ? x / (W - 1) : 0;
|
||||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const idx = (y * W + x) * 4;
|
||||
imgData.data[idx] = r;
|
||||
imgData.data[idx + 1] = g;
|
||||
imgData.data[idx + 2] = b;
|
||||
imgData.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
function _gradientRenderMarkers() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
marker.style.left = `${stop.position * 100}%`;
|
||||
marker.style.background = rgbArrayToHex(stop.color);
|
||||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||||
|
||||
marker.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_gradientSelectedIdx = idx;
|
||||
_gradientStartDrag(e, idx);
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
});
|
||||
|
||||
track.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected stop index and reflect it via CSS classes only —
|
||||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||
*/
|
||||
function _gradientSelectStop(idx) {
|
||||
_gradientSelectedIdx = idx;
|
||||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||
}
|
||||
|
||||
function _gradientRenderStopList() {
|
||||
const list = document.getElementById('gradient-stops-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
|
||||
const hasBidir = !!stop.colorRight;
|
||||
const rightColor = stop.colorRight || stop.color;
|
||||
|
||||
row.innerHTML = `
|
||||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||||
title="Left color">
|
||||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||||
<span class="gradient-stop-spacer"></span>
|
||||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||||
`;
|
||||
|
||||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||
|
||||
// Position
|
||||
const posInput = row.querySelector('.gradient-stop-pos');
|
||||
posInput.addEventListener('change', (e) => {
|
||||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
e.target.value = val.toFixed(2);
|
||||
_gradientStops[idx].position = val;
|
||||
gradientRenderAll();
|
||||
});
|
||||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||
|
||||
// Left color
|
||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||
const markers = document.querySelectorAll('.gradient-marker');
|
||||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Bidirectional toggle
|
||||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||
? null
|
||||
: [..._gradientStops[idx].color];
|
||||
_gradientRenderStopList();
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Right color
|
||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Remove
|
||||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_gradientStops.length > 2) {
|
||||
_gradientStops.splice(idx, 1);
|
||||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
}
|
||||
gradientRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientAddStop(position) {
|
||||
if (position === undefined) {
|
||||
// Find the largest gap between adjacent stops and place in the middle
|
||||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||
let maxGap = 0, gapMid = 0.5;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].position - sorted[i].position;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||||
}
|
||||
}
|
||||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||||
}
|
||||
position = Math.min(1, Math.max(0, position));
|
||||
const color = _gradientInterpolate(_gradientStops, position);
|
||||
_gradientStops.push({ position, color, colorRight: null });
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||
|
||||
function _gradientStartDrag(e, idx) {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||
|
||||
const onMove = (me) => {
|
||||
if (!_gradientDragging) return;
|
||||
const { trackRect } = _gradientDragging;
|
||||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||||
gradientRenderAll();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
_gradientDragging = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track || track._gradientClickBound) return;
|
||||
track._gradientClickBound = true;
|
||||
|
||||
track.addEventListener('click', (e) => {
|
||||
if (_gradientDragging) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
// Ignore clicks very close to an existing marker
|
||||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||||
if (!tooClose) {
|
||||
gradientAddStop(Math.round(pos * 100) / 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
/* Gradient editor moved to css-gradient-editor.js */
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Gradient stop editor — canvas preview, draggable markers, stop list, presets.
|
||||
*
|
||||
* Extracted from color-strips.js. Self-contained module that manages
|
||||
* gradient stops state and renders into the CSS editor modal DOM.
|
||||
*/
|
||||
|
||||
import { t } from '../core/i18n.js';
|
||||
|
||||
/* ── Color conversion utilities ───────────────────────────────── */
|
||||
|
||||
export function rgbArrayToHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||||
export function hexToRgbArray(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
|
||||
/* ── State ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Internal state: array of stop objects.
|
||||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||
*/
|
||||
let _gradientStops = [];
|
||||
let _gradientSelectedIdx = -1;
|
||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||
|
||||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||||
export function getGradientStops() {
|
||||
return _gradientStops;
|
||||
}
|
||||
|
||||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||
|
||||
function _gradientInterpolate(stops, pos) {
|
||||
if (!stops.length) return [128, 128, 128];
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
if (a.position <= pos && pos <= b.position) {
|
||||
const span = b.position - a.position;
|
||||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||||
const lc = a.colorRight || a.color;
|
||||
const rc = b.color;
|
||||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||||
}
|
||||
}
|
||||
return [128, 128, 128];
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientInit(stops) {
|
||||
_gradientStops = stops.map(s => ({
|
||||
position: parseFloat(s.position ?? 0),
|
||||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||
}));
|
||||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||||
_gradientDragging = null;
|
||||
_gradientSetupTrackClick();
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Presets ──────────────────────────────────────────────────── */
|
||||
|
||||
export const GRADIENT_PRESETS = {
|
||||
rainbow: [
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 0.17, color: [255, 165, 0] },
|
||||
{ position: 0.33, color: [255, 255, 0] },
|
||||
{ position: 0.5, color: [0, 255, 0] },
|
||||
{ position: 0.67, color: [0, 100, 255] },
|
||||
{ position: 0.83, color: [75, 0, 130] },
|
||||
{ position: 1.0, color: [148, 0, 211] },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0.0, color: [255, 60, 0] },
|
||||
{ position: 0.3, color: [255, 120, 20] },
|
||||
{ position: 0.6, color: [200, 40, 80] },
|
||||
{ position: 0.8, color: [120, 20, 120] },
|
||||
{ position: 1.0, color: [40, 10, 60] },
|
||||
],
|
||||
ocean: [
|
||||
{ position: 0.0, color: [0, 10, 40] },
|
||||
{ position: 0.3, color: [0, 60, 120] },
|
||||
{ position: 0.6, color: [0, 140, 180] },
|
||||
{ position: 0.8, color: [100, 220, 240] },
|
||||
{ position: 1.0, color: [200, 240, 255] },
|
||||
],
|
||||
forest: [
|
||||
{ position: 0.0, color: [0, 40, 0] },
|
||||
{ position: 0.3, color: [0, 100, 20] },
|
||||
{ position: 0.6, color: [60, 180, 30] },
|
||||
{ position: 0.8, color: [140, 220, 50] },
|
||||
{ position: 1.0, color: [220, 255, 80] },
|
||||
],
|
||||
fire: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.25, color: [80, 0, 0] },
|
||||
{ position: 0.5, color: [255, 40, 0] },
|
||||
{ position: 0.75, color: [255, 160, 0] },
|
||||
{ position: 1.0, color: [255, 255, 60] },
|
||||
],
|
||||
lava: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.3, color: [120, 0, 0] },
|
||||
{ position: 0.6, color: [255, 60, 0] },
|
||||
{ position: 0.8, color: [255, 160, 40] },
|
||||
{ position: 1.0, color: [255, 255, 120] },
|
||||
],
|
||||
aurora: [
|
||||
{ position: 0.0, color: [0, 20, 40] },
|
||||
{ position: 0.25, color: [0, 200, 100] },
|
||||
{ position: 0.5, color: [0, 100, 200] },
|
||||
{ position: 0.75, color: [120, 0, 200] },
|
||||
{ position: 1.0, color: [0, 200, 140] },
|
||||
],
|
||||
ice: [
|
||||
{ position: 0.0, color: [255, 255, 255] },
|
||||
{ position: 0.3, color: [180, 220, 255] },
|
||||
{ position: 0.6, color: [80, 160, 255] },
|
||||
{ position: 0.85, color: [20, 60, 180] },
|
||||
{ position: 1.0, color: [10, 20, 80] },
|
||||
],
|
||||
warm: [
|
||||
{ position: 0.0, color: [255, 255, 80] },
|
||||
{ position: 0.33, color: [255, 160, 0] },
|
||||
{ position: 0.67, color: [255, 60, 0] },
|
||||
{ position: 1.0, color: [160, 0, 0] },
|
||||
],
|
||||
cool: [
|
||||
{ position: 0.0, color: [0, 255, 200] },
|
||||
{ position: 0.33, color: [0, 120, 255] },
|
||||
{ position: 0.67, color: [60, 0, 255] },
|
||||
{ position: 1.0, color: [120, 0, 180] },
|
||||
],
|
||||
neon: [
|
||||
{ position: 0.0, color: [255, 0, 200] },
|
||||
{ position: 0.25, color: [0, 255, 255] },
|
||||
{ position: 0.5, color: [0, 255, 50] },
|
||||
{ position: 0.75, color: [255, 255, 0] },
|
||||
{ position: 1.0, color: [255, 0, 100] },
|
||||
],
|
||||
pastel: [
|
||||
{ position: 0.0, color: [255, 180, 180] },
|
||||
{ position: 0.2, color: [255, 220, 160] },
|
||||
{ position: 0.4, color: [255, 255, 180] },
|
||||
{ position: 0.6, color: [180, 255, 200] },
|
||||
{ position: 0.8, color: [180, 200, 255] },
|
||||
{ position: 1.0, color: [220, 180, 255] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||||
*/
|
||||
export function gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
}
|
||||
|
||||
export function applyGradientPreset(key) {
|
||||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||||
gradientInit(GRADIENT_PRESETS[key]);
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientRenderAll() {
|
||||
_gradientRenderCanvas();
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
}
|
||||
|
||||
function _gradientRenderCanvas() {
|
||||
const canvas = document.getElementById('gradient-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Sync canvas pixel width to its CSS display width
|
||||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const H = canvas.height;
|
||||
const imgData = ctx.createImageData(W, H);
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const pos = W > 1 ? x / (W - 1) : 0;
|
||||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const idx = (y * W + x) * 4;
|
||||
imgData.data[idx] = r;
|
||||
imgData.data[idx + 1] = g;
|
||||
imgData.data[idx + 2] = b;
|
||||
imgData.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
function _gradientRenderMarkers() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
marker.style.left = `${stop.position * 100}%`;
|
||||
marker.style.background = rgbArrayToHex(stop.color);
|
||||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||||
|
||||
marker.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_gradientSelectedIdx = idx;
|
||||
_gradientStartDrag(e, idx);
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
});
|
||||
|
||||
track.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected stop index and reflect it via CSS classes only —
|
||||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||
*/
|
||||
function _gradientSelectStop(idx) {
|
||||
_gradientSelectedIdx = idx;
|
||||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||
}
|
||||
|
||||
function _gradientRenderStopList() {
|
||||
const list = document.getElementById('gradient-stops-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
|
||||
const hasBidir = !!stop.colorRight;
|
||||
const rightColor = stop.colorRight || stop.color;
|
||||
|
||||
row.innerHTML = `
|
||||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||||
title="Left color">
|
||||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||||
<span class="gradient-stop-spacer"></span>
|
||||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||||
`;
|
||||
|
||||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||
|
||||
// Position
|
||||
const posInput = row.querySelector('.gradient-stop-pos');
|
||||
posInput.addEventListener('change', (e) => {
|
||||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
e.target.value = val.toFixed(2);
|
||||
_gradientStops[idx].position = val;
|
||||
gradientRenderAll();
|
||||
});
|
||||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||
|
||||
// Left color
|
||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||
const markers = document.querySelectorAll('.gradient-marker');
|
||||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Bidirectional toggle
|
||||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||
? null
|
||||
: [..._gradientStops[idx].color];
|
||||
_gradientRenderStopList();
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Right color
|
||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Remove
|
||||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_gradientStops.length > 2) {
|
||||
_gradientStops.splice(idx, 1);
|
||||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
}
|
||||
gradientRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientAddStop(position) {
|
||||
if (position === undefined) {
|
||||
// Find the largest gap between adjacent stops and place in the middle
|
||||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||
let maxGap = 0, gapMid = 0.5;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].position - sorted[i].position;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||||
}
|
||||
}
|
||||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||||
}
|
||||
position = Math.min(1, Math.max(0, position));
|
||||
const color = _gradientInterpolate(_gradientStops, position);
|
||||
_gradientStops.push({ position, color, colorRight: null });
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||
|
||||
function _gradientStartDrag(e, idx) {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||
|
||||
const onMove = (me) => {
|
||||
if (!_gradientDragging) return;
|
||||
const { trackRect } = _gradientDragging;
|
||||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||||
gradientRenderAll();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
_gradientDragging = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track || track._gradientClickBound) return;
|
||||
track._gradientClickBound = true;
|
||||
|
||||
track.addEventListener('click', (e) => {
|
||||
if (_gradientDragging) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
// Ignore clicks very close to an existing marker
|
||||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||||
if (!tooClose) {
|
||||
gradientAddStop(Math.round(pos * 100) / 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Dashboard — real-time target status overview.
|
||||
*/
|
||||
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
@@ -418,27 +418,23 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
|
||||
try {
|
||||
// Fire all requests in a single batch to avoid sequential RTTs
|
||||
const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/automations').catch(() => null),
|
||||
fetchWithAuth('/devices').catch(() => null),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
devicesCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||
loadScenePresets(),
|
||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
||||
]);
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
|
||||
const automations = automationsData.automations || [];
|
||||
const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] };
|
||||
const devicesMap = {};
|
||||
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
|
||||
const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] };
|
||||
for (const d of devicesArr) { devicesMap[d.id] = d; }
|
||||
const cssSourceMap = {};
|
||||
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
|
||||
for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
|
||||
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
|
||||
const syncClocks = syncClocksData.clocks || [];
|
||||
|
||||
@@ -782,14 +778,13 @@ export async function dashboardStopTarget(targetId) {
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
try {
|
||||
const [targetsResp, statesResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states'),
|
||||
]);
|
||||
const data = await targetsResp.json();
|
||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||
const states = statesData.states || {};
|
||||
const running = (data.targets || []).filter(t => states[t.id]?.processing);
|
||||
const running = allTargets.filter(t => states[t.id]?.processing);
|
||||
await Promise.all(running.map(t =>
|
||||
fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
|
||||
));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, escapeHtml } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -463,6 +464,7 @@ export async function handleAddDevice(event) {
|
||||
const result = await response.json();
|
||||
console.log('Device added successfully:', result);
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
|
||||
let _deviceTagsInput = null;
|
||||
|
||||
class DeviceSettingsModal extends Modal {
|
||||
constructor() { super('device-settings-modal'); }
|
||||
@@ -30,6 +34,7 @@ class DeviceSettingsModal extends Modal {
|
||||
send_latency: document.getElementById('settings-send-latency')?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
|
||||
zoneMode: _getZoneMode('settings-zone-mode'),
|
||||
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,7 +130,8 @@ export function createDeviceCard(device) {
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>` : ''}`,
|
||||
</div>` : ''}
|
||||
${renderTagChips(device.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
${ICON_SETTINGS}
|
||||
@@ -165,6 +171,7 @@ export async function removeDevice(deviceId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
@@ -323,6 +330,13 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_deviceTagsInput) _deviceTagsInput.destroy();
|
||||
_deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_deviceTagsInput.setValue(device.tags || []);
|
||||
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
|
||||
@@ -338,7 +352,7 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
export function isSettingsDirty() { return settingsModal.isDirty(); }
|
||||
export function forceCloseDeviceSettingsModal() { settingsModal.forceClose(); }
|
||||
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
|
||||
export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
@@ -356,6 +370,7 @@ export async function saveDeviceSettings() {
|
||||
name, url,
|
||||
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
|
||||
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
|
||||
tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [],
|
||||
};
|
||||
const ledCountInput = document.getElementById('settings-led-count');
|
||||
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
|
||||
@@ -386,6 +401,7 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
showToast(t('settings.saved'), 'success');
|
||||
devicesCache.invalidate();
|
||||
settingsModal.forceClose();
|
||||
window.loadDevices();
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
kcWebSockets,
|
||||
PATTERN_RECT_BORDERS,
|
||||
_cachedValueSources, valueSourcesCache, streamsCache,
|
||||
outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _kcTagsInput = null;
|
||||
|
||||
class KCEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('kc-editor-modal');
|
||||
@@ -38,6 +42,7 @@ class KCEditorModal extends Modal {
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
patternTemplateId: document.getElementById('kc-editor-pattern-template').value,
|
||||
brightness_vs: document.getElementById('kc-editor-brightness-vs').value,
|
||||
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -228,6 +233,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
|
||||
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(target.tags)}
|
||||
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${brightnessInt}" data-kc-brightness="${target.id}"
|
||||
@@ -503,12 +509,11 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
|
||||
export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load sources, pattern templates, and value sources in parallel
|
||||
const [sources, patResp, valueSources] = await Promise.all([
|
||||
const [sources, patTemplates, valueSources] = await Promise.all([
|
||||
streamsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
patternTemplatesCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
@@ -538,10 +543,12 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_ensureSourceEntitySelect(sources);
|
||||
_ensurePatternEntitySelect(patTemplates);
|
||||
|
||||
let _editorTags = [];
|
||||
if (targetId) {
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
document.getElementById('kc-editor-id').value = target.id;
|
||||
@@ -557,6 +564,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
|
||||
} else if (cloneData) {
|
||||
_editorTags = cloneData.tags || [];
|
||||
const kcSettings = cloneData.key_colors_settings || {};
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
@@ -593,6 +601,13 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId && !cloneData) _autoGenerateKCName();
|
||||
|
||||
// Tags
|
||||
if (_kcTagsInput) _kcTagsInput.destroy();
|
||||
_kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_kcTagsInput.setValue(_editorTags);
|
||||
|
||||
kcEditorModal.snapshot();
|
||||
kcEditorModal.open();
|
||||
|
||||
@@ -614,6 +629,7 @@ export async function closeKCEditorModal() {
|
||||
}
|
||||
|
||||
export function forceCloseKCEditorModal() {
|
||||
if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; }
|
||||
kcEditorModal.forceClose();
|
||||
set_kcNameManuallyEdited(false);
|
||||
}
|
||||
@@ -641,6 +657,7 @@ export async function saveKCEditor() {
|
||||
const payload = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
|
||||
key_colors_settings: {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
@@ -671,6 +688,7 @@ export async function saveKCEditor() {
|
||||
}
|
||||
|
||||
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
kcEditorModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
@@ -683,9 +701,9 @@ export async function saveKCEditor() {
|
||||
|
||||
export async function cloneKCTarget(targetId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
const targets = await outputTargetsCache.fetch();
|
||||
const target = targets.find(t => t.id === targetId);
|
||||
if (!target) throw new Error('Target not found');
|
||||
showKCEditor(null, target);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -704,6 +722,7 @@ export async function deleteKCTarget(targetId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('kc.deleted'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
|
||||
@@ -16,14 +16,17 @@ import {
|
||||
streamsCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { patternTemplatesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _patternBgEntitySelect = null;
|
||||
let _patternTagsInput = null;
|
||||
|
||||
class PatternTemplateModal extends Modal {
|
||||
constructor() {
|
||||
@@ -35,10 +38,12 @@ class PatternTemplateModal extends Modal {
|
||||
name: document.getElementById('pattern-template-name').value,
|
||||
description: document.getElementById('pattern-template-description').value,
|
||||
rectangles: JSON.stringify(patternEditorRects),
|
||||
tags: JSON.stringify(_patternTagsInput ? _patternTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
setPatternEditorRects([]);
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternEditorBgImage(null);
|
||||
@@ -70,7 +75,8 @@ export function createPatternTemplateCard(pt) {
|
||||
${desc}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
</div>`,
|
||||
</div>
|
||||
${renderTagChips(pt.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
@@ -109,6 +115,8 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternCanvasDragMode(null);
|
||||
|
||||
let _editorTags = [];
|
||||
|
||||
if (templateId) {
|
||||
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
@@ -119,12 +127,14 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
document.getElementById('pattern-template-description').value = tmpl.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
|
||||
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = tmpl.tags || [];
|
||||
} else if (cloneData) {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('pattern-template-description').value = cloneData.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = '';
|
||||
@@ -133,6 +143,11 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorRects([]);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_patternTagsInput.setValue(_editorTags);
|
||||
|
||||
patternModal.snapshot();
|
||||
|
||||
renderPatternRectList();
|
||||
@@ -177,6 +192,7 @@ export async function savePatternTemplate() {
|
||||
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
})),
|
||||
description: description || null,
|
||||
tags: _patternTagsInput ? _patternTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -197,6 +213,7 @@ export async function savePatternTemplate() {
|
||||
}
|
||||
|
||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
patternModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
@@ -209,9 +226,9 @@ export async function savePatternTemplate() {
|
||||
|
||||
export async function clonePatternTemplate(templateId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/pattern-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
const tmpl = await resp.json();
|
||||
const templates = await patternTemplatesCache.fetch();
|
||||
const tmpl = templates.find(t => t.id === templateId);
|
||||
if (!tmpl) throw new Error('Pattern template not found');
|
||||
showPatternTemplateEditor(null, tmpl);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -229,6 +246,7 @@ export async function deletePatternTemplate(templateId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
|
||||
@@ -11,15 +11,20 @@ import { CardSection } from '../core/card-sections.js';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache } from '../core/state.js';
|
||||
import { scenePresetsCache, outputTargetsCache } from '../core/state.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
|
||||
let _editingId = null;
|
||||
let _allTargets = []; // fetched on capture open
|
||||
let _sceneTagsInput = null;
|
||||
|
||||
class ScenePresetEditorModal extends Modal {
|
||||
constructor() { super('scene-preset-editor-modal'); }
|
||||
onForceClose() {
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
}
|
||||
snapshotValues() {
|
||||
const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId).sort().join(',');
|
||||
@@ -27,6 +32,7 @@ class ScenePresetEditorModal extends Modal {
|
||||
name: document.getElementById('scene-preset-editor-name').value,
|
||||
description: document.getElementById('scene-preset-editor-description').value,
|
||||
targets: items,
|
||||
tags: JSON.stringify(_sceneTagsInput ? _sceneTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -61,6 +67,7 @@ export function createSceneCard(preset) {
|
||||
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
|
||||
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(preset.tags)}
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
||||
@@ -129,15 +136,15 @@ export async function openScenePresetCapture() {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue([]);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
@@ -164,27 +171,27 @@ export async function editScenePreset(presetId) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
|
||||
// Pre-add targets already in the preset
|
||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of presetTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
// Pre-add targets already in the preset
|
||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of presetTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue(preset.tags || []);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
@@ -202,6 +209,8 @@ export async function saveScenePreset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (_editingId) {
|
||||
@@ -209,14 +218,14 @@ export async function saveScenePreset() {
|
||||
.map(el => el.dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, target_ids }),
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId);
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, target_ids }),
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,27 +376,27 @@ export async function cloneScenePreset(presetId) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
|
||||
// Pre-add targets from the cloned preset
|
||||
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of clonedTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
// Pre-add targets from the cloned preset
|
||||
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of clonedTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue(preset.tags || []);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -48,10 +48,17 @@ import {
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
|
||||
// ── TagInput instances for modals ──
|
||||
let _captureTemplateTagsInput = null;
|
||||
let _streamTagsInput = null;
|
||||
let _ppTemplateTagsInput = null;
|
||||
let _audioTemplateTagsInput = null;
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
|
||||
@@ -77,6 +84,7 @@ class CaptureTemplateModal extends Modal {
|
||||
name: document.getElementById('template-name').value,
|
||||
description: document.getElementById('template-description').value,
|
||||
engine: document.getElementById('template-engine').value,
|
||||
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
document.querySelectorAll('[data-config-key]').forEach(field => {
|
||||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||||
@@ -85,6 +93,7 @@ class CaptureTemplateModal extends Modal {
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
setCurrentEditingTemplateId(null);
|
||||
set_templateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -104,10 +113,12 @@ class StreamEditorModal extends Modal {
|
||||
source: document.getElementById('stream-source').value,
|
||||
ppTemplate: document.getElementById('stream-pp-template').value,
|
||||
imageSource: document.getElementById('stream-image-source').value,
|
||||
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
document.getElementById('stream-type').disabled = false;
|
||||
set_streamNameManuallyEdited(false);
|
||||
}
|
||||
@@ -121,10 +132,12 @@ class PPTemplateEditorModal extends Modal {
|
||||
name: document.getElementById('pp-template-name').value,
|
||||
description: document.getElementById('pp-template-description').value,
|
||||
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||||
tags: JSON.stringify(_ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
set_modalFilters([]);
|
||||
set_ppTemplateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -138,6 +151,7 @@ class AudioTemplateModal extends Modal {
|
||||
name: document.getElementById('audio-template-name').value,
|
||||
description: document.getElementById('audio-template-description').value,
|
||||
engine: document.getElementById('audio-template-engine').value,
|
||||
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||||
@@ -146,6 +160,7 @@ class AudioTemplateModal extends Modal {
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
setCurrentEditingAudioTemplateId(null);
|
||||
set_audioTemplateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -194,6 +209,11 @@ export async function showAddTemplateModal(cloneData = null) {
|
||||
populateEngineConfig(cloneData.engine_config);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
templateModal.open();
|
||||
templateModal.snapshot();
|
||||
}
|
||||
@@ -221,6 +241,11 @@ export async function editTemplate(templateId) {
|
||||
if (testResults) testResults.style.display = 'none';
|
||||
document.getElementById('template-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_captureTemplateTagsInput.setValue(template.tags || []);
|
||||
|
||||
templateModal.open();
|
||||
templateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -611,7 +636,7 @@ export async function saveTemplate() {
|
||||
const description = document.getElementById('template-description').value.trim();
|
||||
const engineConfig = collectEngineConfig();
|
||||
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
@@ -813,6 +838,11 @@ export async function showAddAudioTemplateModal(cloneData = null) {
|
||||
populateAudioEngineConfig(cloneData.engine_config);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
}
|
||||
@@ -836,6 +866,11 @@ export async function editAudioTemplate(templateId) {
|
||||
|
||||
document.getElementById('audio-template-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioTemplateTagsInput.setValue(template.tags || []);
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -861,7 +896,7 @@ export async function saveAudioTemplate() {
|
||||
const description = document.getElementById('audio-template-description').value.trim();
|
||||
const engineConfig = collectAudioEngineConfig();
|
||||
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
@@ -1235,6 +1270,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
|
||||
</div>
|
||||
${detailsHtml}
|
||||
${renderTagChips(stream.tags)}
|
||||
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
|
||||
@@ -1261,6 +1297,7 @@ function renderPictureSourcesList(streams) {
|
||||
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(template.tags)}
|
||||
${configEntries.length > 0 ? `
|
||||
<div class="template-config-collapse">
|
||||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
|
||||
@@ -1302,7 +1339,8 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
${filterChainHtml}`,
|
||||
${filterChainHtml}
|
||||
${renderTagChips(tmpl.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
@@ -1367,6 +1405,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${renderTagChips(src.tags)}
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
||||
@@ -1392,6 +1431,7 @@ function renderPictureSourcesList(streams) {
|
||||
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(template.tags)}
|
||||
${configEntries.length > 0 ? `
|
||||
<div class="template-config-collapse">
|
||||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
|
||||
@@ -1563,6 +1603,12 @@ export async function showAddStreamModal(presetType, cloneData = null) {
|
||||
}
|
||||
|
||||
_showStreamModalLoading(false);
|
||||
|
||||
// Tags
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_streamTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
streamModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -1616,6 +1662,12 @@ export async function editStream(streamId) {
|
||||
}
|
||||
|
||||
_showStreamModalLoading(false);
|
||||
|
||||
// Tags
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_streamTagsInput.setValue(stream.tags || []);
|
||||
|
||||
streamModal.snapshot();
|
||||
} catch (error) {
|
||||
console.error('Error loading stream:', error);
|
||||
@@ -1772,7 +1824,7 @@ export async function saveStream() {
|
||||
|
||||
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
|
||||
const payload = { name, description: description || null };
|
||||
const payload = { name, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
|
||||
if (!streamId) payload.stream_type = streamType;
|
||||
|
||||
if (streamType === 'raw') {
|
||||
@@ -2429,6 +2481,11 @@ export async function showAddPPTemplateModal(cloneData = null) {
|
||||
document.getElementById('pp-template-description').value = cloneData.description || '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_ppTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
ppTemplateModal.open();
|
||||
ppTemplateModal.snapshot();
|
||||
}
|
||||
@@ -2455,6 +2512,11 @@ export async function editPPTemplate(templateId) {
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
// Tags
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_ppTemplateTagsInput.setValue(tmpl.tags || []);
|
||||
|
||||
ppTemplateModal.open();
|
||||
ppTemplateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -2471,7 +2533,7 @@ export async function savePPTemplate() {
|
||||
|
||||
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
|
||||
|
||||
const payload = { name, filters: collectFilters(), description: description || null };
|
||||
const payload = { name, filters: collectFilters(), description: description || null, tags: _ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
@@ -9,18 +9,26 @@ import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _syncClockTagsInput = null;
|
||||
|
||||
class SyncClockModal extends Modal {
|
||||
constructor() { super('sync-clock-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('sync-clock-name').value,
|
||||
speed: document.getElementById('sync-clock-speed').value,
|
||||
description: document.getElementById('sync-clock-description').value,
|
||||
tags: JSON.stringify(_syncClockTagsInput ? _syncClockTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,6 +56,11 @@ export async function showSyncClockModal(editData) {
|
||||
document.getElementById('sync-clock-description').value = '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
|
||||
_syncClockTagsInput = new TagInput(document.getElementById('sync-clock-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_syncClockTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
||||
|
||||
syncClockModal.open();
|
||||
syncClockModal.snapshot();
|
||||
}
|
||||
@@ -69,7 +82,7 @@ export async function saveSyncClock() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, speed, description };
|
||||
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
@@ -199,6 +212,7 @@ export function createSyncClockCard(clock) {
|
||||
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
|
||||
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
|
||||
</div>
|
||||
${renderTagChips(clock.tags)}
|
||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ledPreviewWebSockets,
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
streamsCache, audioSourcesCache, syncClocksCache,
|
||||
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||
|
||||
@@ -140,6 +142,7 @@ function _updateSubTabCounts(subTabs) {
|
||||
|
||||
// --- Editor state ---
|
||||
let _editorCssSources = []; // populated when editor opens
|
||||
let _targetTagsInput = null;
|
||||
|
||||
class TargetEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -157,6 +160,7 @@ class TargetEditorModal extends Modal {
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
|
||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -311,14 +315,12 @@ function _ensureTargetEntitySelects() {
|
||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load devices, CSS sources, and value sources for dropdowns
|
||||
const [devicesResp, cssResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/color-strip-sources'),
|
||||
const [devices, cssSources] = await Promise.all([
|
||||
devicesCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||
set_targetEditorDevices(devices);
|
||||
_editorCssSources = cssSources;
|
||||
|
||||
@@ -335,11 +337,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
let _editorTags = [];
|
||||
if (targetId) {
|
||||
// Editing existing target
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
|
||||
document.getElementById('target-editor-id').value = target.id;
|
||||
document.getElementById('target-editor-name').value = target.name;
|
||||
@@ -362,6 +366,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
_editorTags = cloneData.tags || [];
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
deviceSelect.value = cloneData.device_id || '';
|
||||
@@ -420,6 +425,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
_updateFpsRecommendation();
|
||||
_updateBrightnessThresholdVisibility();
|
||||
|
||||
// Tags
|
||||
if (_targetTagsInput) _targetTagsInput.destroy();
|
||||
_targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_targetTagsInput.setValue(_editorTags);
|
||||
|
||||
targetEditorModal.snapshot();
|
||||
targetEditorModal.open();
|
||||
|
||||
@@ -440,6 +452,7 @@ export async function closeTargetEditorModal() {
|
||||
}
|
||||
|
||||
export function forceCloseTargetEditorModal() {
|
||||
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
||||
targetEditorModal.forceClose();
|
||||
}
|
||||
|
||||
@@ -473,6 +486,7 @@ export async function saveTargetEditor() {
|
||||
keepalive_interval: standbyInterval,
|
||||
adaptive_fps: adaptiveFps,
|
||||
protocol,
|
||||
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -496,6 +510,7 @@ export async function saveTargetEditor() {
|
||||
}
|
||||
|
||||
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
targetEditorModal.forceClose();
|
||||
await loadTargetsTab();
|
||||
} catch (error) {
|
||||
@@ -546,41 +561,26 @@ export async function loadTargetsTab() {
|
||||
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, CSS sources, pattern templates in parallel;
|
||||
// use DataCache for picture sources, audio sources, value sources, sync clocks
|
||||
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||
fetchWithAuth('/devices'),
|
||||
fetchWithAuth('/output-targets'),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
// Fetch all entities via DataCache
|
||||
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||
devicesCache.fetch().catch(() => []),
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
patternTemplatesCache.fetch().catch(() => []),
|
||||
streamsCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch().catch(() => []),
|
||||
audioSourcesCache.fetch().catch(() => []),
|
||||
syncClocksCache.fetch().catch(() => []),
|
||||
]);
|
||||
|
||||
const devicesData = await devicesResp.json();
|
||||
const devices = devicesData.devices || [];
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
|
||||
let colorStripSourceMap = {};
|
||||
if (cssResp && cssResp.ok) {
|
||||
const cssData = await cssResp.json();
|
||||
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
|
||||
}
|
||||
cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });
|
||||
|
||||
let pictureSourceMap = {};
|
||||
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
|
||||
|
||||
let patternTemplates = [];
|
||||
let patternTemplateMap = {};
|
||||
if (patResp && patResp.ok) {
|
||||
const patData = await patResp.json();
|
||||
patternTemplates = patData.templates || [];
|
||||
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
||||
}
|
||||
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
||||
|
||||
let valueSourceMap = {};
|
||||
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
|
||||
@@ -959,6 +959,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(target.tags)}
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
@@ -1082,15 +1083,14 @@ export async function stopAllKCTargets() {
|
||||
|
||||
async function _stopAllByType(targetType) {
|
||||
try {
|
||||
const [targetsResp, statesResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states'),
|
||||
]);
|
||||
const data = await targetsResp.json();
|
||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||
const states = statesData.states || {};
|
||||
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
|
||||
const running = (data.targets || []).filter(t => typeMatch(t) && states[t.id]?.processing);
|
||||
const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
|
||||
if (!running.length) {
|
||||
showToast(t('targets.stop_all.none_running'), 'info');
|
||||
return;
|
||||
@@ -1156,6 +1156,7 @@ export async function deleteTarget(targetId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('targets.deleted'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('target.error.delete_failed'), 'error');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
@@ -31,10 +32,15 @@ export { getValueSourceIcon };
|
||||
// ── EntitySelect instances for value source editor ──
|
||||
let _vsAudioSourceEntitySelect = null;
|
||||
let _vsPictureSourceEntitySelect = null;
|
||||
let _vsTagsInput = null;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
constructor() { super('value-source-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
return {
|
||||
@@ -58,6 +64,7 @@ class ValueSourceModal extends Modal {
|
||||
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
|
||||
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
|
||||
schedule: JSON.stringify(_getScheduleFromUI()),
|
||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -241,6 +248,11 @@ export async function showValueSourceModal(editData) {
|
||||
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
|
||||
|
||||
// Tags
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
_vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_vsTagsInput.setValue(editData ? (editData.tags || []) : []);
|
||||
|
||||
valueSourceModal.open();
|
||||
valueSourceModal.snapshot();
|
||||
}
|
||||
@@ -293,7 +305,7 @@ export async function saveValueSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
const payload = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'static') {
|
||||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||||
@@ -648,6 +660,7 @@ export function createValueSourceCard(src) {
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${renderTagChips(src.tags)}
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
|
||||
|
||||
Reference in New Issue
Block a user