Improve device cards, stream/template UI, and add PP template testing

- Move WLED UI button into URL badge as clickable link on device cards
- Remove version label from device cards
- Show PP template name on processed stream cards
- Display filter chain as pills on processing template cards
- Add processing template test with source stream selector
- Pre-load PP templates when viewing streams to fix race condition
- Add ESC key handling for all modals
- Add filter chain CSS styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 15:28:58 +03:00
parent c8ebb60f99
commit 4f9c30ef06
7 changed files with 390 additions and 19 deletions

View File

@@ -57,10 +57,33 @@ function closeLightbox(event) {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker();
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
} else {
// Close topmost visible modal
const modals = [
{ id: 'test-pp-template-modal', close: closeTestPPTemplateModal },
{ id: 'test-stream-modal', close: closeTestStreamModal },
{ id: 'test-template-modal', close: closeTestTemplateModal },
{ id: 'stream-modal', close: closeStreamModal },
{ id: 'pp-template-modal', close: closePPTemplateModal },
{ id: 'template-modal', close: closeTemplateModal },
{ id: 'device-settings-modal', close: forceCloseDeviceSettingsModal },
{ id: 'capture-settings-modal', close: forceCloseCaptureSettingsModal },
{ id: 'calibration-modal', close: forceCloseCalibrationModal },
{ id: 'stream-selector-modal', close: forceCloseStreamSelectorModal },
{ id: 'add-device-modal', close: closeAddDeviceModal },
];
for (const m of modals) {
const el = document.getElementById(m.id);
if (el && el.style.display === 'flex') {
m.close();
break;
}
}
}
}
});
@@ -648,13 +671,12 @@ function createDeviceCard(device) {
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url ? `<span class="device-url-badge" title="${escapeHtml(device.url)}">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span>` : ''}
${device.url ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
${healthLabel}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="card-subtitle">
${wledVersion ? `<span class="card-meta">v${wledVersion}</span>` : ''}
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.wled_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
@@ -707,11 +729,6 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
${device.url ? `
<a class="btn btn-icon btn-secondary" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}">
🌐
</a>
` : ''}
</div>
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
</div>
@@ -3033,6 +3050,17 @@ let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() {
try {
// Ensure PP templates are cached so processed stream cards can show filter info
if (_cachedPPTemplates.length === 0) {
try {
if (_availableFilters.length === 0) {
const fr = await fetchWithAuth('/filters');
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
}
const pr = await fetchWithAuth('/postprocessing-templates');
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
} catch (e) { console.warn('Could not pre-load PP templates for streams:', e); }
}
const response = await fetchWithAuth('/picture-streams');
if (!response.ok) {
throw new Error(`Failed to load streams: ${response.status}`);
@@ -3102,10 +3130,19 @@ function renderPictureStreamsList(streams) {
// Find source stream name and PP template name
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
// Find PP template name
let ppTemplateHtml = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) {
ppTemplateHtml = `<div class="template-config"><strong>${t('streams.pp_template')}</strong> ${escapeHtml(ppTmpl.name)}</div>`;
}
}
detailsHtml = `
<div class="template-config">
<strong>${t('streams.source')}</strong> ${sourceName}
</div>
${ppTemplateHtml}
`;
}
@@ -3456,6 +3493,95 @@ function displayStreamTestResults(result) {
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
// ===== PP Template Test =====
let _currentTestPPTemplateId = null;
async function showTestPPTemplateModal(templateId) {
_currentTestPPTemplateId = templateId;
restorePPTestDuration();
// Populate source stream selector
const select = document.getElementById('test-pp-source-stream');
select.innerHTML = '';
// Ensure streams are cached
if (_cachedStreams.length === 0) {
try {
const resp = await fetchWithAuth('/picture-streams');
if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; }
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
for (const s of _cachedStreams) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
select.appendChild(opt);
}
// Auto-select last used stream
const lastStream = localStorage.getItem('lastPPTestStreamId');
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
select.value = lastStream;
}
const modal = document.getElementById('test-pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); };
}
function closeTestPPTemplateModal() {
document.getElementById('test-pp-template-modal').style.display = 'none';
unlockBody();
_currentTestPPTemplateId = null;
}
function updatePPTestDuration(value) {
document.getElementById('test-pp-duration-value').textContent = value;
localStorage.setItem('lastPPTestDuration', value);
}
function restorePPTestDuration() {
const saved = localStorage.getItem('lastPPTestDuration') || '5';
document.getElementById('test-pp-duration').value = saved;
document.getElementById('test-pp-duration-value').textContent = saved;
}
async function runPPTemplateTest() {
if (!_currentTestPPTemplateId) return;
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
if (!sourceStreamId) {
showToast(t('postprocessing.test.error.no_stream'), 'error');
return;
}
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
try {
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
method: 'POST',
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
} catch (error) {
console.error('Error running PP template test:', error);
hideOverlaySpinner();
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
}
}
// ===== Processing Templates =====
async function loadAvailableFilters() {
@@ -3514,12 +3640,12 @@ function renderPPTemplatesList(templates) {
}
const renderCard = (tmpl) => {
// Build config entries from filter list
const filterRows = (tmpl.filters || []).map(fi => {
const filterName = _getFilterName(fi.filter_id);
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
}).join('');
// Build filter chain pills
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return `
<div class="template-card" data-pp-template-id="${tmpl.id}">
@@ -3530,13 +3656,11 @@ function renderPPTemplatesList(templates) {
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
<details class="template-config-details">
<summary>${t('postprocessing.config.show')}</summary>
<table class="config-table">
${filterRows}
</table>
</details>
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>