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