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