Add Key Colors target type for extracting colors from screen regions
Introduce a new "key_colors" target type alongside WLED targets, enabling real-time color extraction from configurable screen rectangles with average/median/dominant modes, temporal smoothing, and WebSocket streaming. - Split WledPictureTarget into its own module, add KeyColorsPictureTarget - Add KC target lifecycle to ProcessorManager (register, start/stop, processing loop) - Extend API routes and schemas for KC targets (CRUD, settings, state, metrics, colors) - Add WebSocket endpoint for real-time color updates with auth - Add KC sub-tab in Targets UI with editor modal and live color swatches - Add EN and RU translations for all key colors strings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3998,7 +3998,7 @@ async function loadTargetsTab() {
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch state + metrics for each target
|
||||
// Fetch state + metrics for each target (+ colors for KC targets)
|
||||
const targetsWithState = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
@@ -4006,7 +4006,14 @@ async function loadTargetsTab() {
|
||||
const state = stateResp.ok ? await stateResp.json() : {};
|
||||
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
|
||||
const metrics = metricsResp.ok ? await metricsResp.json() : {};
|
||||
return { ...target, state, metrics };
|
||||
let latestColors = null;
|
||||
if (target.target_type === 'key_colors' && state.processing) {
|
||||
try {
|
||||
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
|
||||
if (colorsResp.ok) latestColors = await colorsResp.json();
|
||||
} catch {}
|
||||
}
|
||||
return { ...target, state, metrics, latestColors };
|
||||
} catch {
|
||||
return target;
|
||||
}
|
||||
@@ -4017,14 +4024,16 @@ async function loadTargetsTab() {
|
||||
const deviceMap = {};
|
||||
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
||||
|
||||
// Group by type (currently only WLED)
|
||||
// Group by type
|
||||
const wledDevices = devicesWithState;
|
||||
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
|
||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
||||
|
||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
|
||||
];
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
||||
@@ -4054,7 +4063,21 @@ async function loadTargetsTab() {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel;
|
||||
// Key Colors panel
|
||||
const kcPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
||||
<div class="devices-grid">
|
||||
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showKCEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
||||
|
||||
// Attach event listeners and fetch WLED brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
@@ -4062,6 +4085,21 @@ async function loadTargetsTab() {
|
||||
fetchDeviceBrightness(device.id);
|
||||
});
|
||||
|
||||
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
||||
const processingKCIds = new Set();
|
||||
kcTargets.forEach(target => {
|
||||
if (target.state && target.state.processing) {
|
||||
processingKCIds.add(target.id);
|
||||
if (!kcWebSockets[target.id]) {
|
||||
connectKCWebSocket(target.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Disconnect WebSockets for targets no longer processing
|
||||
Object.keys(kcWebSockets).forEach(id => {
|
||||
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load targets tab:', error);
|
||||
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
|
||||
@@ -4203,3 +4241,425 @@ async function deleteTarget(targetId) {
|
||||
showToast('Failed to delete target', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== KEY COLORS TARGET CARD =====
|
||||
|
||||
function createKCTargetCard(target, sourceMap) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
|
||||
const source = sourceMap[target.picture_source_id];
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
const rectCount = (kcSettings.rectangles || []).length;
|
||||
|
||||
// Render initial color swatches from pre-fetched REST data
|
||||
let swatchesHtml = '';
|
||||
const latestColors = target.latestColors && target.latestColors.colors;
|
||||
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
|
||||
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
|
||||
<div class="kc-swatch">
|
||||
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
|
||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else if (isProcessing) {
|
||||
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card" data-kc-target-id="${target.id}">
|
||||
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
${escapeHtml(target.name)}
|
||||
<span class="badge">KEY COLORS</span>
|
||||
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
||||
${swatchesHtml}
|
||||
</div>
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||||
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||
<div class="metric-label">${t('targets.metrics.frames')}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||
<div class="metric-label">${t('targets.metrics.errors')}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
|
||||
⏹️
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
|
||||
▶️
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ===== KEY COLORS EDITOR =====
|
||||
|
||||
let kcEditorRectangles = [];
|
||||
let kcEditorInitialValues = {};
|
||||
let _kcNameManuallyEdited = false;
|
||||
|
||||
function _autoGenerateKCName() {
|
||||
if (_kcNameManuallyEdited) return;
|
||||
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!sourceName) return;
|
||||
const rectCount = kcEditorRectangles.length;
|
||||
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
||||
const modeName = t(`kc.interpolation.${mode}`);
|
||||
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
|
||||
}
|
||||
|
||||
function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
|
||||
kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
|
||||
renderKCRectangles();
|
||||
_autoGenerateKCName();
|
||||
}
|
||||
|
||||
function removeKCRectangle(index) {
|
||||
kcEditorRectangles.splice(index, 1);
|
||||
renderKCRectangles();
|
||||
_autoGenerateKCName();
|
||||
}
|
||||
|
||||
function renderKCRectangles() {
|
||||
const container = document.getElementById('kc-rect-list');
|
||||
if (!container) return;
|
||||
|
||||
if (kcEditorRectangles.length === 0) {
|
||||
container.innerHTML = `<div class="kc-rect-empty">${t('kc.rect.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = `<div class="kc-rect-labels">
|
||||
<span>${t('kc.rect.name')}</span>
|
||||
<span>${t('kc.rect.x')}</span>
|
||||
<span>${t('kc.rect.y')}</span>
|
||||
<span>${t('kc.rect.width')}</span>
|
||||
<span>${t('kc.rect.height')}</span>
|
||||
<span></span>
|
||||
</div>`;
|
||||
|
||||
const rows = kcEditorRectangles.map((rect, i) => `
|
||||
<div class="kc-rect-row">
|
||||
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('kc.rect.name')}" onchange="kcEditorRectangles[${i}].name = this.value">
|
||||
<input type="number" value="${rect.x}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].x = parseFloat(this.value) || 0">
|
||||
<input type="number" value="${rect.y}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].y = parseFloat(this.value) || 0">
|
||||
<input type="number" value="${rect.width}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].width = parseFloat(this.value) || 0.01">
|
||||
<input type="number" value="${rect.height}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].height = parseFloat(this.value) || 0.01">
|
||||
<button type="button" class="kc-rect-remove-btn" onclick="removeKCRectangle(${i})" title="${t('kc.rect.remove')}">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = labels + rows;
|
||||
}
|
||||
|
||||
async function showKCEditor(targetId = null) {
|
||||
try {
|
||||
// Load sources for dropdown
|
||||
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
|
||||
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
||||
|
||||
// Populate source select (no empty option — source is required for KC targets)
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (targetId) {
|
||||
// Editing existing target
|
||||
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
document.getElementById('kc-editor-id').value = target.id;
|
||||
document.getElementById('kc-editor-name').value = target.name;
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
|
||||
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
|
||||
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.edit');
|
||||
|
||||
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = '';
|
||||
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
||||
document.getElementById('kc-editor-fps').value = 10;
|
||||
document.getElementById('kc-editor-fps-value').textContent = '10';
|
||||
document.getElementById('kc-editor-interpolation').value = 'average';
|
||||
document.getElementById('kc-editor-smoothing').value = 0.3;
|
||||
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.add');
|
||||
|
||||
kcEditorRectangles = [];
|
||||
}
|
||||
|
||||
renderKCRectangles();
|
||||
|
||||
// Auto-name: reset flag and wire listeners
|
||||
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
|
||||
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
|
||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
||||
|
||||
// Trigger auto-name after dropdowns are populated (create mode only)
|
||||
if (!targetId) _autoGenerateKCName();
|
||||
|
||||
kcEditorInitialValues = {
|
||||
name: document.getElementById('kc-editor-name').value,
|
||||
source: sourceSelect.value,
|
||||
fps: document.getElementById('kc-editor-fps').value,
|
||||
interpolation: document.getElementById('kc-editor-interpolation').value,
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
rectangles: JSON.stringify(kcEditorRectangles),
|
||||
};
|
||||
|
||||
const modal = document.getElementById('kc-editor-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setupBackdropClose(modal, closeKCEditorModal);
|
||||
|
||||
document.getElementById('kc-editor-error').style.display = 'none';
|
||||
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to open KC editor:', error);
|
||||
showToast('Failed to open key colors editor', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function isKCEditorDirty() {
|
||||
return (
|
||||
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
|
||||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
|
||||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
|
||||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
|
||||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
|
||||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
|
||||
);
|
||||
}
|
||||
|
||||
async function closeKCEditorModal() {
|
||||
if (isKCEditorDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceCloseKCEditorModal();
|
||||
}
|
||||
|
||||
function forceCloseKCEditorModal() {
|
||||
document.getElementById('kc-editor-modal').style.display = 'none';
|
||||
document.getElementById('kc-editor-error').style.display = 'none';
|
||||
unlockBody();
|
||||
kcEditorInitialValues = {};
|
||||
kcEditorRectangles = [];
|
||||
}
|
||||
|
||||
async function saveKCEditor() {
|
||||
const targetId = document.getElementById('kc-editor-id').value;
|
||||
const name = document.getElementById('kc-editor-name').value.trim();
|
||||
const sourceId = document.getElementById('kc-editor-source').value;
|
||||
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
|
||||
const interpolation = document.getElementById('kc-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
|
||||
const errorEl = document.getElementById('kc-editor-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('kc.error.required');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (kcEditorRectangles.length === 0) {
|
||||
errorEl.textContent = t('kc.error.no_rectangles');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
key_colors_settings: {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing,
|
||||
rectangles: kcEditorRectangles.map(r => ({
|
||||
name: r.name,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'key_colors';
|
||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
}
|
||||
|
||||
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
|
||||
forceCloseKCEditorModal();
|
||||
await loadTargets();
|
||||
} catch (error) {
|
||||
console.error('Error saving KC target:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKCTarget(targetId) {
|
||||
const confirmed = await showConfirm(t('kc.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
disconnectKCWebSocket(targetId);
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('kc.deleted'), 'success');
|
||||
loadTargets();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to delete key colors target', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== KEY COLORS WEBSOCKET =====
|
||||
|
||||
const kcWebSockets = {};
|
||||
|
||||
function connectKCWebSocket(targetId) {
|
||||
// Disconnect existing connection if any
|
||||
disconnectKCWebSocket(targetId);
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
if (!key) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
updateKCColorSwatches(targetId, data.colors || {});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse KC WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
delete kcWebSockets[targetId];
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`KC WebSocket error for ${targetId}:`, error);
|
||||
};
|
||||
|
||||
kcWebSockets[targetId] = ws;
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectKCWebSocket(targetId) {
|
||||
const ws = kcWebSockets[targetId];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
delete kcWebSockets[targetId];
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectAllKCWebSockets() {
|
||||
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
|
||||
}
|
||||
|
||||
function updateKCColorSwatches(targetId, colors) {
|
||||
const container = document.getElementById(`kc-swatches-${targetId}`);
|
||||
if (!container) return;
|
||||
|
||||
const entries = Object.entries(colors);
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map(([name, color]) => `
|
||||
<div class="kc-swatch">
|
||||
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
|
||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user