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:
2026-02-11 20:18:45 +03:00
parent e0877a9b16
commit 705179f73f
3 changed files with 120 additions and 159 deletions

View File

@@ -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 ? '&#x25B6;' : '&#x25BC;';
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

View File

@@ -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>

View File

@@ -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;
}