Refactor stream and template cards: collapsible groups, icon pills, compact layout
- Make picture stream groups expandable/collapsible with localStorage persistence - Replace text labels with icon pill badges on stream and capture template cards - Remove section description text from all tabs - Add auto-validation on edit for static image streams - Show full URL on static image cards instead of truncating at 50 chars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2448,6 +2448,7 @@ function renderTemplatesList(templates) {
|
||||
|
||||
const renderCard = (template) => {
|
||||
const engineIcon = getEngineIcon(template.engine_type);
|
||||
const configEntries = Object.entries(template.engine_config);
|
||||
|
||||
return `
|
||||
<div class="template-card" data-template-id="${template.id}">
|
||||
@@ -2457,14 +2458,15 @@ function renderTemplatesList(templates) {
|
||||
${engineIcon} ${escapeHtml(template.name)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-config">
|
||||
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${Object.keys(template.engine_config).length > 0 ? `
|
||||
${configEntries.length > 0 ? `
|
||||
<details class="template-config-details">
|
||||
<summary>${t('templates.config.show')}</summary>
|
||||
<table class="config-table">
|
||||
${Object.entries(template.engine_config).map(([key, val]) => `
|
||||
${configEntries.map(([key, val]) => `
|
||||
<tr>
|
||||
<td class="config-key">${escapeHtml(key)}</td>
|
||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||||
@@ -2472,9 +2474,7 @@ function renderTemplatesList(templates) {
|
||||
`).join('')}
|
||||
</table>
|
||||
</details>
|
||||
` : `
|
||||
<div class="template-no-config">${t('templates.config.none')}</div>
|
||||
`}
|
||||
` : ''}
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
|
||||
🧪
|
||||
@@ -3087,50 +3087,46 @@ async function loadPictureStreams() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStreamGroup(groupKey) {
|
||||
const stored = JSON.parse(localStorage.getItem('streamGroupState') || '{}');
|
||||
stored[groupKey] = !stored[groupKey];
|
||||
localStorage.setItem('streamGroupState', JSON.stringify(stored));
|
||||
renderPictureStreamsList(_cachedStreams);
|
||||
}
|
||||
|
||||
function isStreamGroupCollapsed(groupKey) {
|
||||
const stored = JSON.parse(localStorage.getItem('streamGroupState') || '{}');
|
||||
return !!stored[groupKey];
|
||||
}
|
||||
|
||||
function renderStreamGroup(groupKey, icon, titleKey, count, addType, addLabelKey, cardsHtml) {
|
||||
const collapsed = isStreamGroupCollapsed(groupKey);
|
||||
const chevron = collapsed ? '▶' : '▼';
|
||||
return `<div class="stream-group${collapsed ? ' collapsed' : ''}">
|
||||
<div class="stream-group-header" onclick="toggleStreamGroup('${groupKey}')">
|
||||
<span class="stream-group-chevron">${chevron}</span>
|
||||
<span class="stream-group-icon">${icon}</span>
|
||||
<span class="stream-group-title">${t(titleKey)}</span>
|
||||
<span class="stream-group-count">${count}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${cardsHtml}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('${addType}')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t(addLabelKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPictureStreamsList(streams) {
|
||||
const container = document.getElementById('streams-list');
|
||||
|
||||
if (streams.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖥️</span>
|
||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.raw')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🎨</span>
|
||||
<span class="stream-group-title">${t('streams.group.processed')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.processed')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
container.innerHTML =
|
||||
renderStreamGroup('raw', '🖥️', 'streams.group.raw', 0, 'raw', 'streams.add.raw', '') +
|
||||
renderStreamGroup('static_image', '🖼️', 'streams.group.static_image', 0, 'static_image', 'streams.add.static_image', '') +
|
||||
renderStreamGroup('processed', '🎨', 'streams.group.processed', 0, 'processed', 'streams.add.processed', '');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3146,49 +3142,33 @@ function renderPictureStreamsList(streams) {
|
||||
|
||||
let detailsHtml = '';
|
||||
if (stream.stream_type === 'raw') {
|
||||
let captureTemplateHtml = '';
|
||||
let capTmplName = '';
|
||||
if (stream.capture_template_id) {
|
||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||
if (capTmpl) {
|
||||
captureTemplateHtml = `<div class="template-config"><strong>${t('streams.capture_template')}</strong> ${escapeHtml(capTmpl.name)}</div>`;
|
||||
}
|
||||
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||||
}
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
||||
</div>
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
||||
</div>
|
||||
${captureTemplateHtml}
|
||||
`;
|
||||
detailsHtml = `<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
|
||||
</div>`;
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
// 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 = '';
|
||||
let ppTmplName = '';
|
||||
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>`;
|
||||
}
|
||||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||||
}
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.source')}</strong> ${sourceName}
|
||||
</div>
|
||||
${ppTemplateHtml}
|
||||
`;
|
||||
detailsHtml = `<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
|
||||
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
|
||||
</div>`;
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
const src = stream.image_source || '';
|
||||
const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src;
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.image_source')}</strong>
|
||||
</div>
|
||||
<div class="stream-card-image-source" title="${escapeHtml(src)}">${escapeHtml(truncated)}</div>
|
||||
`;
|
||||
detailsHtml = `<div class="stream-card-props">
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -3218,57 +3198,10 @@ function renderPictureStreamsList(streams) {
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Screen Capture streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖥️</span>
|
||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||
<span class="stream-group-count">${rawStreams.length}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${rawStreams.map(renderCard).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.raw')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Static Image streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">${staticImageStreams.length}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${staticImageStreams.map(renderCard).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Processed streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🎨</span>
|
||||
<span class="stream-group-title">${t('streams.group.processed')}</span>
|
||||
<span class="stream-group-count">${processedStreams.length}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${processedStreams.map(renderCard).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.processed')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
container.innerHTML =
|
||||
renderStreamGroup('raw', '🖥️', 'streams.group.raw', rawStreams.length, 'raw', 'streams.add.raw', rawStreams.map(renderCard).join('')) +
|
||||
renderStreamGroup('static_image', '🖼️', 'streams.group.static_image', staticImageStreams.length, 'static_image', 'streams.add.static_image', staticImageStreams.map(renderCard).join('')) +
|
||||
renderStreamGroup('processed', '🎨', 'streams.group.processed', processedStreams.length, 'processed', 'streams.add.processed', processedStreams.map(renderCard).join(''));
|
||||
}
|
||||
|
||||
function onStreamTypeChange() {
|
||||
@@ -3323,9 +3256,14 @@ async function editStream(streamId) {
|
||||
|
||||
// Set type (hidden input)
|
||||
document.getElementById('stream-type').value = stream.stream_type;
|
||||
// Clear static image preview
|
||||
// Clear static image preview and wire up auto-validation
|
||||
_lastValidatedImageSource = '';
|
||||
const imgSrcInput = document.getElementById('stream-image-source');
|
||||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||||
imgSrcInput.onblur = () => validateStaticImage();
|
||||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns before setting values
|
||||
|
||||
@@ -42,14 +42,6 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-panel active" id="tab-devices">
|
||||
<p class="section-tip">
|
||||
<strong><span data-i18n="devices.wled_config">WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
|
||||
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" data-i18n="devices.wled_link">official WLED app</a>
|
||||
<span data-i18n="devices.wled_note_or">or the built-in</span>
|
||||
<a href="#" class="wled-webui-link" data-i18n="devices.wled_webui_link">WLED Web UI</a>
|
||||
<span data-i18n="devices.wled_note_webui">(open your device's IP in a browser).</span>
|
||||
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
|
||||
</p>
|
||||
<div id="devices-list" class="devices-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
@@ -57,33 +49,18 @@
|
||||
|
||||
|
||||
<div class="tab-panel" id="tab-streams">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="streams.description">
|
||||
Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
|
||||
</span>
|
||||
</p>
|
||||
<div id="streams-list">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-templates">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="templates.description">
|
||||
Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.
|
||||
</span>
|
||||
</p>
|
||||
<div id="templates-list" class="templates-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-pp-templates">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="postprocessing.description">
|
||||
Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.
|
||||
</span>
|
||||
</p>
|
||||
<div id="pp-templates-list" class="templates-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
@@ -2192,6 +2192,30 @@ input:-webkit-autofill:focus {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.stream-group-header:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stream-group-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stream-group.collapsed .stream-group-header {
|
||||
margin-bottom: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.stream-group.collapsed .templates-grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-group-icon {
|
||||
@@ -2395,10 +2419,32 @@ input:-webkit-autofill:focus {
|
||||
.validation-status.loading {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stream-card-image-source {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
margin-top: 4px;
|
||||
.stream-card-props {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stream-card-prop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--border-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.stream-card-prop-full {
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user