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:
2026-03-09 22:20:19 +03:00
parent 2712c6682e
commit 30fa107ef7
120 changed files with 2471 additions and 1949 deletions

View File

@@ -255,6 +255,26 @@ export const automationsCacheObj = new DataCache({
});
automationsCacheObj.subscribe(v => { _automationsCache = v; });
export const colorStripSourcesCache = new DataCache({
endpoint: '/color-strip-sources',
extractData: json => json.sources || [],
});
export const devicesCache = new DataCache({
endpoint: '/devices',
extractData: json => json.devices || [],
});
export const outputTargetsCache = new DataCache({
endpoint: '/output-targets',
extractData: json => json.targets || [],
});
export const patternTemplatesCache = new DataCache({
endpoint: '/pattern-templates',
extractData: json => json.templates || [],
});
export const scenePresetsCache = new DataCache({
endpoint: '/scene-presets',
extractData: json => json.presets || [],

View File

@@ -0,0 +1,225 @@
/**
* TagInput — reusable chip-based tag input with autocomplete.
*
* Usage:
* import { TagInput } from '../core/tag-input.js';
*
* const tagInput = new TagInput(document.getElementById('my-container'));
* tagInput.setValue(['bedroom', 'gaming']);
* tagInput.getValue(); // ['bedroom', 'gaming']
* tagInput.destroy();
*
* The component fetches available tags from GET /api/v1/tags for autocomplete.
* Tags are stored lowercase, trimmed, deduplicated.
*/
import { fetchWithAuth } from './api.js';
let _allTagsCache = null;
let _allTagsFetchPromise = null;
/** Fetch all tags from API (cached). Call invalidateTagsCache() after mutations. */
export async function fetchAllTags() {
if (_allTagsCache) return _allTagsCache;
if (_allTagsFetchPromise) return _allTagsFetchPromise;
_allTagsFetchPromise = fetchWithAuth('/tags')
.then(r => r.json())
.then(data => {
_allTagsCache = data.tags || [];
_allTagsFetchPromise = null;
return _allTagsCache;
})
.catch(() => {
_allTagsFetchPromise = null;
return [];
});
return _allTagsFetchPromise;
}
/** Call after create/update to refresh autocomplete suggestions. */
export function invalidateTagsCache() {
_allTagsCache = null;
}
/**
* Render tag chips HTML for display on cards.
* @param {string[]} tags
* @returns {string} HTML string
*/
export function renderTagChips(tags) {
if (!tags || !tags.length) return '';
return `<div class="card-tags">${tags.map(tag =>
`<span class="card-tag">${_escapeHtml(tag)}</span>`
).join('')}</div>`;
}
function _escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export class TagInput {
/**
* @param {HTMLElement} container Element to render the tag input into
* @param {object} [opts]
* @param {string} [opts.placeholder] Placeholder text for input
*/
constructor(container, opts = {}) {
this._container = container;
this._tags = [];
this._placeholder = opts.placeholder || 'Add tag...';
this._dropdownVisible = false;
this._selectedIdx = -1;
this._render();
this._bindEvents();
}
getValue() {
return [...this._tags];
}
setValue(tags) {
this._tags = (tags || []).map(t => t.toLowerCase().trim()).filter(Boolean);
this._tags = [...new Set(this._tags)];
this._renderChips();
}
destroy() {
this._container.innerHTML = '';
this._hideDropdown();
}
// ── private ──
_render() {
this._container.innerHTML = `
<div class="tag-input-wrap">
<div class="tag-input-chips"></div>
<input type="text" class="tag-input-field" placeholder="${_escapeHtml(this._placeholder)}" autocomplete="off" spellcheck="false">
<div class="tag-input-dropdown"></div>
</div>
`;
this._chipsEl = this._container.querySelector('.tag-input-chips');
this._inputEl = this._container.querySelector('.tag-input-field');
this._dropdownEl = this._container.querySelector('.tag-input-dropdown');
}
_renderChips() {
this._chipsEl.innerHTML = this._tags.map((tag, i) =>
`<span class="tag-chip">${_escapeHtml(tag)}<button type="button" class="tag-chip-remove" data-idx="${i}">&times;</button></span>`
).join('');
}
_bindEvents() {
// Chip remove buttons
this._chipsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.tag-chip-remove');
if (!btn) return;
const idx = parseInt(btn.dataset.idx, 10);
this._tags.splice(idx, 1);
this._renderChips();
});
// Input keydown
this._inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
if (this._dropdownVisible && this._selectedIdx >= 0) {
// Select from dropdown
e.preventDefault();
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
if (items[this._selectedIdx]) {
this._addTag(items[this._selectedIdx].dataset.tag);
}
} else if (this._inputEl.value.trim()) {
e.preventDefault();
this._addTag(this._inputEl.value);
}
} else if (e.key === 'Backspace' && !this._inputEl.value && this._tags.length) {
this._tags.pop();
this._renderChips();
} else if (e.key === 'ArrowDown' && this._dropdownVisible) {
e.preventDefault();
this._moveSelection(1);
} else if (e.key === 'ArrowUp' && this._dropdownVisible) {
e.preventDefault();
this._moveSelection(-1);
} else if (e.key === 'Escape') {
this._hideDropdown();
}
});
// Input typing → autocomplete
this._inputEl.addEventListener('input', () => {
this._updateDropdown();
});
// Focus → show dropdown
this._inputEl.addEventListener('focus', () => {
this._updateDropdown();
});
// Blur → hide (with delay so clicks register)
this._inputEl.addEventListener('blur', () => {
setTimeout(() => this._hideDropdown(), 200);
});
// Dropdown click
this._dropdownEl.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent blur
const item = e.target.closest('.tag-dropdown-item');
if (item) this._addTag(item.dataset.tag);
});
}
_addTag(raw) {
const tag = raw.toLowerCase().trim().replace(/,/g, '');
if (!tag || this._tags.includes(tag)) {
this._inputEl.value = '';
this._hideDropdown();
return;
}
this._tags.push(tag);
this._renderChips();
this._inputEl.value = '';
this._hideDropdown();
invalidateTagsCache();
}
async _updateDropdown() {
const query = this._inputEl.value.toLowerCase().trim();
const allTags = await fetchAllTags();
// Filter: exclude already-selected tags, match query
const suggestions = allTags
.filter(t => !this._tags.includes(t))
.filter(t => !query || t.includes(query))
.slice(0, 8);
if (!suggestions.length) {
this._hideDropdown();
return;
}
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
).join('');
this._dropdownEl.style.display = 'block';
this._dropdownVisible = true;
this._selectedIdx = 0;
}
_hideDropdown() {
this._dropdownEl.style.display = 'none';
this._dropdownVisible = false;
this._selectedIdx = -1;
}
_moveSelection(delta) {
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
if (!items.length) return;
items[this._selectedIdx]?.classList.remove('tag-dropdown-active');
this._selectedIdx = Math.max(0, Math.min(items.length - 1, this._selectedIdx + delta));
items[this._selectedIdx]?.classList.add('tag-dropdown-active');
items[this._selectedIdx]?.scrollIntoView({ block: 'nearest' });
}
}

View File

@@ -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(() => ({}));

View File

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

View File

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

View File

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

View File

@@ -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 01, 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 */

View File

@@ -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 01, 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);
}
});
}

View File

@@ -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(() => {})
));

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&#x2715;</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">&#x2715;</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">&#x2715;</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">&#x2715;</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();
}

View File

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

View File

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

View File

@@ -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} &lt;${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');

View File

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