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>

View File

@@ -490,6 +490,34 @@
</div>
</div>
<!-- Test PP Template Modal -->
<div id="test-pp-template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="postprocessing.test.title">Test Processing Template</h2>
<button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label data-i18n="postprocessing.test.source_stream">Source Stream:</label>
<select id="test-pp-source-stream"></select>
</div>
<div class="form-group">
<label for="test-pp-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-pp-duration-value">5</span>
</label>
<input type="range" id="test-pp-duration" min="1" max="10" step="1" value="5" oninput="updatePPTestDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
</button>
</div>
</div>
</div>
<!-- Picture Stream Modal -->
<div id="stream-modal" class="modal">
<div class="modal-content">

View File

@@ -260,6 +260,11 @@
"postprocessing.error.required": "Please fill in all required fields",
"postprocessing.error.delete": "Failed to delete processing template",
"postprocessing.config.show": "Show settings",
"postprocessing.test.title": "Test Processing Template",
"postprocessing.test.source_stream": "Source Stream:",
"postprocessing.test.running": "Testing processing template...",
"postprocessing.test.error.no_stream": "Please select a source stream",
"postprocessing.test.error.failed": "Processing template test failed",
"device.button.stream_selector": "Stream Settings",
"device.stream_settings.title": "📺 Stream Settings",
"device.stream_selector.label": "Picture Stream:",

View File

@@ -260,6 +260,11 @@
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
"postprocessing.error.delete": "Не удалось удалить шаблон обработки",
"postprocessing.config.show": "Показать настройки",
"postprocessing.test.title": "Тест шаблона обработки",
"postprocessing.test.source_stream": "Источник потока:",
"postprocessing.test.running": "Тестирование шаблона обработки...",
"postprocessing.test.error.no_stream": "Пожалуйста, выберите источник потока",
"postprocessing.test.error.failed": "Тест шаблона обработки не удался",
"device.button.stream_selector": "Настройки потока",
"device.stream_settings.title": "📺 Настройки потока",
"device.stream_selector.label": "Видеопоток:",

View File

@@ -243,6 +243,7 @@ section {
color: var(--primary-color);
}
.card-remove-btn {
position: absolute;
top: 10px;
@@ -284,6 +285,9 @@ section {
}
.device-url-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-secondary);
@@ -292,6 +296,16 @@ section {
border-radius: 10px;
letter-spacing: 0.03em;
font-family: monospace;
text-decoration: none;
transition: background 0.2s;
}
.device-url-badge:hover {
background: var(--text-muted);
}
.device-url-icon {
font-size: 0.6rem;
}
.card-subtitle {
@@ -1791,6 +1805,27 @@ input:-webkit-autofill:focus {
margin-bottom: 8px;
}
.filter-chain {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.filter-chain-item {
font-size: 0.7rem;
background: var(--border-color);
color: var(--text-secondary);
padding: 2px 8px;
border-radius: 10px;
}
.filter-chain-arrow {
font-size: 0.7rem;
color: var(--text-muted);
}
.template-config-details {
margin: 12px 0;
font-size: 13px;