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

@@ -9,6 +9,7 @@ import {
kcWebSockets,
PATTERN_RECT_BORDERS,
_cachedValueSources, valueSourcesCache, streamsCache,
outputTargetsCache, patternTemplatesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -21,9 +22,12 @@ import {
} from '../core/icons.js';
import * as P from '../core/icon-paths.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';
let _kcTagsInput = null;
class KCEditorModal extends Modal {
constructor() {
super('kc-editor-modal');
@@ -38,6 +42,7 @@ class KCEditorModal extends Modal {
smoothing: document.getElementById('kc-editor-smoothing').value,
patternTemplateId: document.getElementById('kc-editor-pattern-template').value,
brightness_vs: document.getElementById('kc-editor-brightness-vs').value,
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
};
}
}
@@ -228,6 +233,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${brightnessInt}" data-kc-brightness="${target.id}"
@@ -503,12 +509,11 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
export async function showKCEditor(targetId = null, cloneData = null) {
try {
// Load sources, pattern templates, and value sources in parallel
const [sources, patResp, valueSources] = await Promise.all([
const [sources, patTemplates, valueSources] = await Promise.all([
streamsCache.fetch().catch(() => []),
fetchWithAuth('/pattern-templates').catch(() => null),
patternTemplatesCache.fetch().catch(() => []),
valueSourcesCache.fetch(),
]);
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
// Populate source select
const sourceSelect = document.getElementById('kc-editor-source');
@@ -538,10 +543,12 @@ export async function showKCEditor(targetId = null, cloneData = null) {
_ensureSourceEntitySelect(sources);
_ensurePatternEntitySelect(patTemplates);
let _editorTags = [];
if (targetId) {
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
_editorTags = target.tags || [];
const kcSettings = target.key_colors_settings || {};
document.getElementById('kc-editor-id').value = target.id;
@@ -557,6 +564,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
} else if (cloneData) {
_editorTags = cloneData.tags || [];
const kcSettings = cloneData.key_colors_settings || {};
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)';
@@ -593,6 +601,13 @@ export async function showKCEditor(targetId = null, cloneData = null) {
patSelect.onchange = () => _autoGenerateKCName();
if (!targetId && !cloneData) _autoGenerateKCName();
// Tags
if (_kcTagsInput) _kcTagsInput.destroy();
_kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), {
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
});
_kcTagsInput.setValue(_editorTags);
kcEditorModal.snapshot();
kcEditorModal.open();
@@ -614,6 +629,7 @@ export async function closeKCEditorModal() {
}
export function forceCloseKCEditorModal() {
if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; }
kcEditorModal.forceClose();
set_kcNameManuallyEdited(false);
}
@@ -641,6 +657,7 @@ export async function saveKCEditor() {
const payload = {
name,
picture_source_id: sourceId,
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
key_colors_settings: {
fps,
interpolation_mode: interpolation,
@@ -671,6 +688,7 @@ export async function saveKCEditor() {
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
outputTargetsCache.invalidate();
kcEditorModal.forceClose();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
@@ -683,9 +701,9 @@ export async function saveKCEditor() {
export async function cloneKCTarget(targetId) {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
const targets = await outputTargetsCache.fetch();
const target = targets.find(t => t.id === targetId);
if (!target) throw new Error('Target not found');
showKCEditor(null, target);
} catch (error) {
if (error.isAuth) return;
@@ -704,6 +722,7 @@ export async function deleteKCTarget(targetId) {
});
if (response.ok) {
showToast(t('kc.deleted'), 'success');
outputTargetsCache.invalidate();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {