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,18 +9,26 @@ import { Modal } from '../core/modal.js';
import { showToast, showConfirm } from '../core/ui.js';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { loadPictureSources } from './streams.js';
// ── Modal ──
let _syncClockTagsInput = null;
class SyncClockModal extends Modal {
constructor() { super('sync-clock-modal'); }
onForceClose() {
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
}
snapshotValues() {
return {
name: document.getElementById('sync-clock-name').value,
speed: document.getElementById('sync-clock-speed').value,
description: document.getElementById('sync-clock-description').value,
tags: JSON.stringify(_syncClockTagsInput ? _syncClockTagsInput.getValue() : []),
};
}
}
@@ -48,6 +56,11 @@ export async function showSyncClockModal(editData) {
document.getElementById('sync-clock-description').value = '';
}
// Tags
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
_syncClockTagsInput = new TagInput(document.getElementById('sync-clock-tags-container'), { placeholder: t('tags.placeholder') });
_syncClockTagsInput.setValue(isEdit ? (editData.tags || []) : []);
syncClockModal.open();
syncClockModal.snapshot();
}
@@ -69,7 +82,7 @@ export async function saveSyncClock() {
return;
}
const payload = { name, speed, description };
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
try {
const method = id ? 'PUT' : 'POST';
@@ -199,6 +212,7 @@ export function createSyncClockCard(clock) {
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
</div>
${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>