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:
@@ -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>
|
||||
|
||||
@@ -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">✕</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">
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Видеопоток:",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user