Add KC target test button, API docs header link, and UI polish
- Add POST /api/v1/picture-targets/{target_id}/test endpoint for single-frame
color extraction preview on Key Colors targets
- Add test button on KC target cards that opens lightbox with spinner,
displays captured frame with rectangle overlays and color swatches
- Add API docs link in WebUI header
- Swap confirm dialog button colors (No=red, Yes=neutral)
- Remove type badges from WLED and KC target cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,10 @@ function closeLightbox(event) {
|
||||
// Revoke blob URL if one was used
|
||||
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||
img.src = '';
|
||||
img.style.display = '';
|
||||
document.getElementById('lightbox-stats').style.display = 'none';
|
||||
const spinner = lightbox.querySelector('.lightbox-spinner');
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
@@ -4152,7 +4155,6 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${escapeHtml(target.name)}
|
||||
<span class="badge">${target.target_type.toUpperCase()}</span>
|
||||
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -4295,7 +4297,6 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||
<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>
|
||||
@@ -4339,6 +4340,9 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||
▶️
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
|
||||
🧪
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
@@ -4347,6 +4351,131 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
// ===== KEY COLORS TEST =====
|
||||
|
||||
async function testKCTarget(targetId) {
|
||||
// Show lightbox immediately with a spinner
|
||||
const lightbox = document.getElementById('image-lightbox');
|
||||
const lbImg = document.getElementById('lightbox-image');
|
||||
const statsEl = document.getElementById('lightbox-stats');
|
||||
lbImg.style.display = 'none';
|
||||
lbImg.src = '';
|
||||
statsEl.style.display = 'none';
|
||||
|
||||
// Insert spinner if not already present
|
||||
let spinner = lightbox.querySelector('.lightbox-spinner');
|
||||
if (!spinner) {
|
||||
spinner = document.createElement('div');
|
||||
spinner.className = 'lightbox-spinner loading-spinner';
|
||||
lightbox.querySelector('.lightbox-content').prepend(spinner);
|
||||
}
|
||||
spinner.style.display = '';
|
||||
|
||||
lightbox.classList.add('active');
|
||||
lockBody();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || response.statusText);
|
||||
}
|
||||
const result = await response.json();
|
||||
displayKCTestResults(result);
|
||||
} catch (e) {
|
||||
closeLightbox();
|
||||
showToast(t('kc.test.error') + ': ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function displayKCTestResults(result) {
|
||||
const srcImg = new window.Image();
|
||||
srcImg.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = srcImg.width;
|
||||
canvas.height = srcImg.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw captured frame
|
||||
ctx.drawImage(srcImg, 0, 0);
|
||||
|
||||
const w = srcImg.width;
|
||||
const h = srcImg.height;
|
||||
|
||||
// Draw each rectangle with extracted color overlay
|
||||
result.rectangles.forEach((rect, i) => {
|
||||
const px = rect.x * w;
|
||||
const py = rect.y * h;
|
||||
const pw = rect.width * w;
|
||||
const ph = rect.height * h;
|
||||
|
||||
const color = rect.color;
|
||||
const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
|
||||
|
||||
// Semi-transparent fill with the extracted color
|
||||
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
|
||||
// Border using pattern colors for distinction
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(px, py, pw, ph);
|
||||
|
||||
// Color swatch in top-left corner of rect
|
||||
const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
|
||||
|
||||
// Name label with shadow for readability
|
||||
const fontSize = Math.max(12, Math.min(18, pw * 0.06));
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const labelX = px + swatchSize + 10;
|
||||
const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillText(rect.name, labelX, labelY);
|
||||
|
||||
// Hex label below name
|
||||
ctx.font = `${fontSize - 2}px monospace`;
|
||||
ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
|
||||
|
||||
// Build stats HTML
|
||||
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
|
||||
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
|
||||
result.rectangles.forEach((rect) => {
|
||||
const c = rect.color;
|
||||
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
|
||||
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
|
||||
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
|
||||
statsHtml += `</div>`;
|
||||
});
|
||||
statsHtml += `</div>`;
|
||||
|
||||
// Hide spinner, show result in the already-open lightbox
|
||||
const spinner = document.querySelector('.lightbox-spinner');
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
|
||||
const lbImg = document.getElementById('lightbox-image');
|
||||
const statsEl = document.getElementById('lightbox-stats');
|
||||
lbImg.src = dataUrl;
|
||||
lbImg.style.display = '';
|
||||
statsEl.innerHTML = statsHtml;
|
||||
statsEl.style.display = '';
|
||||
};
|
||||
srcImg.src = result.image;
|
||||
}
|
||||
|
||||
// ===== KEY COLORS EDITOR =====
|
||||
|
||||
let kcEditorInitialValues = {};
|
||||
|
||||
Reference in New Issue
Block a user