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

@@ -6,12 +6,16 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
import { devicesCache } from '../core/state.js';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
let _deviceTagsInput = null;
class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); }
@@ -30,6 +34,7 @@ class DeviceSettingsModal extends Modal {
send_latency: document.getElementById('settings-send-latency')?.value || '0',
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
zoneMode: _getZoneMode('settings-zone-mode'),
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
};
}
@@ -125,7 +130,8 @@ export function createDeviceCard(device) {
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>` : ''}`,
</div>` : ''}
${renderTagChips(device.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
@@ -165,6 +171,7 @@ export async function removeDevice(deviceId) {
});
if (response.ok) {
showToast(t('device.removed'), 'success');
devicesCache.invalidate();
window.loadDevices();
} else {
const error = await response.json();
@@ -323,6 +330,13 @@ export async function showSettings(deviceId) {
}
}
// Tags
if (_deviceTagsInput) _deviceTagsInput.destroy();
_deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), {
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
});
_deviceTagsInput.setValue(device.tags || []);
settingsModal.snapshot();
settingsModal.open();
@@ -338,7 +352,7 @@ export async function showSettings(deviceId) {
}
export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { settingsModal.forceClose(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
@@ -356,6 +370,7 @@ export async function saveDeviceSettings() {
name, url,
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [],
};
const ledCountInput = document.getElementById('settings-led-count');
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
@@ -386,6 +401,7 @@ export async function saveDeviceSettings() {
}
showToast(t('settings.saved'), 'success');
devicesCache.invalidate();
settingsModal.forceClose();
window.loadDevices();
} catch (err) {