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

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