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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user