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:
@@ -16,14 +16,17 @@ import {
|
||||
streamsCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { patternTemplatesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _patternBgEntitySelect = null;
|
||||
let _patternTagsInput = null;
|
||||
|
||||
class PatternTemplateModal extends Modal {
|
||||
constructor() {
|
||||
@@ -35,10 +38,12 @@ class PatternTemplateModal extends Modal {
|
||||
name: document.getElementById('pattern-template-name').value,
|
||||
description: document.getElementById('pattern-template-description').value,
|
||||
rectangles: JSON.stringify(patternEditorRects),
|
||||
tags: JSON.stringify(_patternTagsInput ? _patternTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
setPatternEditorRects([]);
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternEditorBgImage(null);
|
||||
@@ -70,7 +75,8 @@ export function createPatternTemplateCard(pt) {
|
||||
${desc}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
</div>`,
|
||||
</div>
|
||||
${renderTagChips(pt.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
@@ -109,6 +115,8 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternCanvasDragMode(null);
|
||||
|
||||
let _editorTags = [];
|
||||
|
||||
if (templateId) {
|
||||
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
@@ -119,12 +127,14 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
document.getElementById('pattern-template-description').value = tmpl.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
|
||||
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = tmpl.tags || [];
|
||||
} else if (cloneData) {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('pattern-template-description').value = cloneData.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = '';
|
||||
@@ -133,6 +143,11 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorRects([]);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_patternTagsInput.setValue(_editorTags);
|
||||
|
||||
patternModal.snapshot();
|
||||
|
||||
renderPatternRectList();
|
||||
@@ -177,6 +192,7 @@ export async function savePatternTemplate() {
|
||||
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
})),
|
||||
description: description || null,
|
||||
tags: _patternTagsInput ? _patternTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -197,6 +213,7 @@ export async function savePatternTemplate() {
|
||||
}
|
||||
|
||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
patternModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
@@ -209,9 +226,9 @@ export async function savePatternTemplate() {
|
||||
|
||||
export async function clonePatternTemplate(templateId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/pattern-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
const tmpl = await resp.json();
|
||||
const templates = await patternTemplatesCache.fetch();
|
||||
const tmpl = templates.find(t => t.id === templateId);
|
||||
if (!tmpl) throw new Error('Pattern template not found');
|
||||
showPatternTemplateEditor(null, tmpl);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -229,6 +246,7 @@ export async function deletePatternTemplate(templateId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user