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

@@ -10,6 +10,7 @@ import {
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -28,6 +29,7 @@ import {
} from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -140,6 +142,7 @@ function _updateSubTabCounts(subTabs) {
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _targetTagsInput = null;
class TargetEditorModal extends Modal {
constructor() {
@@ -157,6 +160,7 @@ class TargetEditorModal extends Modal {
fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
};
}
}
@@ -311,14 +315,12 @@ function _ensureTargetEntitySelects() {
export async function showTargetEditor(targetId = null, cloneData = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/color-strip-sources'),
const [devices, cssSources] = await Promise.all([
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
valueSourcesCache.fetch(),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
_editorCssSources = cssSources;
@@ -335,11 +337,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt);
});
let _editorTags = [];
if (targetId) {
// Editing existing target
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 || [];
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
@@ -362,6 +366,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || [];
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || '';
@@ -420,6 +425,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
_updateFpsRecommendation();
_updateBrightnessThresholdVisibility();
// Tags
if (_targetTagsInput) _targetTagsInput.destroy();
_targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
});
_targetTagsInput.setValue(_editorTags);
targetEditorModal.snapshot();
targetEditorModal.open();
@@ -440,6 +452,7 @@ export async function closeTargetEditorModal() {
}
export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
targetEditorModal.forceClose();
}
@@ -473,6 +486,7 @@ export async function saveTargetEditor() {
keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps,
protocol,
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
};
try {
@@ -496,6 +510,7 @@ export async function saveTargetEditor() {
}
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
outputTargetsCache.invalidate();
targetEditorModal.forceClose();
await loadTargetsTab();
} catch (error) {
@@ -546,41 +561,26 @@ export async function loadTargetsTab() {
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try {
// Fetch devices, targets, CSS sources, pattern templates in parallel;
// use DataCache for picture sources, audio sources, value sources, sync clocks
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/output-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
]);
const devicesData = await devicesResp.json();
const devices = devicesData.devices || [];
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let colorStripSourceMap = {};
if (cssResp && cssResp.ok) {
const cssData = await cssResp.json();
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
}
cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });
let pictureSourceMap = {};
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplates = [];
let patternTemplateMap = {};
if (patResp && patResp.ok) {
const patData = await patResp.json();
patternTemplates = patData.templates || [];
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
}
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
let valueSourceMap = {};
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
@@ -959,6 +959,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
${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>` : ''}
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
@@ -1082,15 +1083,14 @@ export async function stopAllKCTargets() {
async function _stopAllByType(targetType) {
try {
const [targetsResp, statesResp] = await Promise.all([
fetchWithAuth('/output-targets'),
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const data = await targetsResp.json();
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
const states = statesData.states || {};
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
const running = (data.targets || []).filter(t => typeMatch(t) && states[t.id]?.processing);
const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
if (!running.length) {
showToast(t('targets.stop_all.none_running'), 'info');
return;
@@ -1156,6 +1156,7 @@ export async function deleteTarget(targetId) {
});
if (response.ok) {
showToast(t('targets.deleted'), 'success');
outputTargetsCache.invalidate();
} else {
const error = await response.json();
showToast(error.detail || t('target.error.delete_failed'), 'error');