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 renderCard = (template) => {
|
||||||
const engineIcon = getEngineIcon(template.engine_type);
|
const engineIcon = getEngineIcon(template.engine_type);
|
||||||
|
const configEntries = Object.entries(template.engine_config);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="template-card" data-template-id="${template.id}">
|
<div class="template-card" data-template-id="${template.id}">
|
||||||
@@ -2457,14 +2458,15 @@ function renderTemplatesList(templates) {
|
|||||||
${engineIcon} ${escapeHtml(template.name)}
|
${engineIcon} ${escapeHtml(template.name)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="template-config">
|
<div class="stream-card-props">
|
||||||
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
|
<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>
|
</div>
|
||||||
${Object.keys(template.engine_config).length > 0 ? `
|
${configEntries.length > 0 ? `
|
||||||
<details class="template-config-details">
|
<details class="template-config-details">
|
||||||
<summary>${t('templates.config.show')}</summary>
|
<summary>${t('templates.config.show')}</summary>
|
||||||
<table class="config-table">
|
<table class="config-table">
|
||||||
${Object.entries(template.engine_config).map(([key, val]) => `
|
${configEntries.map(([key, val]) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="config-key">${escapeHtml(key)}</td>
|
<td class="config-key">${escapeHtml(key)}</td>
|
||||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
<td class="config-value">${escapeHtml(String(val))}</td>
|
||||||
@@ -2472,9 +2474,7 @@ function renderTemplatesList(templates) {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
` : `
|
` : ''}
|
||||||
<div class="template-no-config">${t('templates.config.none')}</div>
|
|
||||||
`}
|
|
||||||
<div class="template-card-actions">
|
<div class="template-card-actions">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
|
<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) {
|
function renderPictureStreamsList(streams) {
|
||||||
const container = document.getElementById('streams-list');
|
const container = document.getElementById('streams-list');
|
||||||
|
|
||||||
if (streams.length === 0) {
|
if (streams.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML =
|
||||||
<div class="stream-group">
|
renderStreamGroup('raw', '🖥️', 'streams.group.raw', 0, 'raw', 'streams.add.raw', '') +
|
||||||
<div class="stream-group-header">
|
renderStreamGroup('static_image', '🖼️', 'streams.group.static_image', 0, 'static_image', 'streams.add.static_image', '') +
|
||||||
<span class="stream-group-icon">🖥️</span>
|
renderStreamGroup('processed', '🎨', 'streams.group.processed', 0, 'processed', 'streams.add.processed', '');
|
||||||
<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>`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3146,49 +3142,33 @@ function renderPictureStreamsList(streams) {
|
|||||||
|
|
||||||
let detailsHtml = '';
|
let detailsHtml = '';
|
||||||
if (stream.stream_type === 'raw') {
|
if (stream.stream_type === 'raw') {
|
||||||
let captureTemplateHtml = '';
|
let capTmplName = '';
|
||||||
if (stream.capture_template_id) {
|
if (stream.capture_template_id) {
|
||||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||||
if (capTmpl) {
|
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||||||
captureTemplateHtml = `<div class="template-config"><strong>${t('streams.capture_template')}</strong> ${escapeHtml(capTmpl.name)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
detailsHtml = `
|
detailsHtml = `<div class="stream-card-props">
|
||||||
<div class="template-config">
|
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
|
||||||
</div>
|
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
|
||||||
<div class="template-config">
|
</div>`;
|
||||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
|
||||||
</div>
|
|
||||||
${captureTemplateHtml}
|
|
||||||
`;
|
|
||||||
} else if (stream.stream_type === 'processed') {
|
} 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 sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||||
// Find PP template name
|
let ppTmplName = '';
|
||||||
let ppTemplateHtml = '';
|
|
||||||
if (stream.postprocessing_template_id) {
|
if (stream.postprocessing_template_id) {
|
||||||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||||||
if (ppTmpl) {
|
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||||||
ppTemplateHtml = `<div class="template-config"><strong>${t('streams.pp_template')}</strong> ${escapeHtml(ppTmpl.name)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
detailsHtml = `
|
detailsHtml = `<div class="stream-card-props">
|
||||||
<div class="template-config">
|
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
|
||||||
<strong>${t('streams.source')}</strong> ${sourceName}
|
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
|
||||||
</div>
|
</div>`;
|
||||||
${ppTemplateHtml}
|
|
||||||
`;
|
|
||||||
} else if (stream.stream_type === 'static_image') {
|
} else if (stream.stream_type === 'static_image') {
|
||||||
const src = stream.image_source || '';
|
const src = stream.image_source || '';
|
||||||
const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src;
|
detailsHtml = `<div class="stream-card-props">
|
||||||
detailsHtml = `
|
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>
|
||||||
<div class="template-config">
|
</div>`;
|
||||||
<strong>${t('streams.image_source')}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stream-card-image-source" title="${escapeHtml(src)}">${escapeHtml(truncated)}</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -3218,57 +3198,10 @@ function renderPictureStreamsList(streams) {
|
|||||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||||
|
|
||||||
let html = '';
|
container.innerHTML =
|
||||||
|
renderStreamGroup('raw', '🖥️', 'streams.group.raw', rawStreams.length, 'raw', 'streams.add.raw', rawStreams.map(renderCard).join('')) +
|
||||||
// Screen Capture streams section
|
renderStreamGroup('static_image', '🖼️', 'streams.group.static_image', staticImageStreams.length, 'static_image', 'streams.add.static_image', staticImageStreams.map(renderCard).join('')) +
|
||||||
html += `<div class="stream-group">
|
renderStreamGroup('processed', '🎨', 'streams.group.processed', processedStreams.length, 'processed', 'streams.add.processed', processedStreams.map(renderCard).join(''));
|
||||||
<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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStreamTypeChange() {
|
function onStreamTypeChange() {
|
||||||
@@ -3323,9 +3256,14 @@ async function editStream(streamId) {
|
|||||||
|
|
||||||
// Set type (hidden input)
|
// Set type (hidden input)
|
||||||
document.getElementById('stream-type').value = stream.stream_type;
|
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-preview-container').style.display = 'none';
|
||||||
document.getElementById('stream-image-validation-status').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();
|
onStreamTypeChange();
|
||||||
|
|
||||||
// Populate dropdowns before setting values
|
// Populate dropdowns before setting values
|
||||||
|
|||||||
@@ -42,14 +42,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel active" id="tab-devices">
|
<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 id="devices-list" class="devices-grid">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,33 +49,18 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-streams">
|
<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 id="streams-list">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-templates">
|
<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 id="templates-list" class="templates-grid">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-pp-templates">
|
<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 id="pp-templates-list" class="templates-grid">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2192,6 +2192,30 @@ input:-webkit-autofill:focus {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 2px solid var(--border-color);
|
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 {
|
.stream-group-icon {
|
||||||
@@ -2395,10 +2419,32 @@ input:-webkit-autofill:focus {
|
|||||||
.validation-status.loading {
|
.validation-status.loading {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.stream-card-image-source {
|
.stream-card-props {
|
||||||
font-size: 0.7rem;
|
display: flex;
|
||||||
color: var(--text-muted);
|
flex-wrap: wrap;
|
||||||
word-break: break-all;
|
gap: 6px;
|
||||||
margin-top: 4px;
|
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