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:
@@ -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} <${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');
|
||||
|
||||
Reference in New Issue
Block a user