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

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