Add collapsible card sections with name filtering

Introduces CardSection class that wraps each card grid with a collapsible
header and inline filter input. Collapse state persists in localStorage,
filter value survives auto-refresh re-renders. When filter is active the
add-card button is hidden. Applied to all 13 sections across Targets,
Sources, and Profiles tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 00:46:14 +03:00
parent 808037775f
commit 166ec351b1
7 changed files with 246 additions and 118 deletions

View File

@@ -603,6 +603,61 @@
border-bottom: 1px solid var(--border-color);
}
/* ── Collapsible card sections (cs-*) ── */
.cs-header {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.cs-chevron {
font-size: 0.65rem;
color: var(--text-secondary);
width: 12px;
display: inline-block;
flex-shrink: 0;
}
.cs-title {
flex-shrink: 0;
}
.cs-count {
background: var(--border-color);
color: var(--text-secondary);
border-radius: 10px;
padding: 0 7px;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.cs-filter {
margin-left: auto;
width: 160px;
max-width: 40%;
padding: 3px 8px !important;
font-size: 0.78rem !important;
border: 1px solid var(--border-color) !important;
border-radius: 12px !important;
background: var(--bg-color) !important;
color: var(--text-color) !important;
outline: none;
box-shadow: none !important;
}
.cs-filter:focus {
border-color: var(--primary-color) !important;
}
.cs-filter::placeholder {
color: var(--text-secondary);
font-size: 0.75rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.templates-grid {

View File

@@ -0,0 +1,143 @@
/**
* CardSection — collapsible section with name filtering for card grids.
*
* Usage:
* const section = new CardSection('led-devices', {
* titleKey: 'targets.section.devices',
* gridClass: 'devices-grid',
* addCardOnclick: "showAddDevice()",
* });
*
* // In the render function (building innerHTML):
* html += section.render(cardsHtml, cardCount);
*
* // After container.innerHTML is set:
* section.bind();
*/
import { t } from './i18n.js';
const STORAGE_KEY = 'sections_collapsed';
function _getCollapsedMap() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
catch { return {}; }
}
export class CardSection {
/**
* @param {string} sectionKey Unique key for localStorage persistence
* @param {object} opts
* @param {string} opts.titleKey i18n key for the section title
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid'
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
*/
constructor(sectionKey, { titleKey, gridClass, addCardOnclick }) {
this.sectionKey = sectionKey;
this.titleKey = titleKey;
this.gridClass = gridClass;
this.addCardOnclick = addCardOnclick || '';
this._filterValue = '';
}
/** Returns section HTML. Call during innerHTML building. */
render(cardsHtml, count) {
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
const chevron = isCollapsed ? '\u25B6' : '\u25BC';
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
const addCard = this.addCardOnclick
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
: '';
return `
<div class="subtab-section" data-card-section="${this.sectionKey}">
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
<span class="cs-chevron">${chevron}</span>
<span class="cs-title">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span>
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
placeholder="${t('section.filter.placeholder')}" autocomplete="off">
</div>
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
${cardsHtml}
${addCard}
</div>
</div>`;
}
/** Attach event listeners after innerHTML is set. */
bind() {
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`);
if (!header || !content) return;
header.addEventListener('click', (e) => {
if (e.target.closest('.cs-filter')) return;
this._toggleCollapse(header, content);
});
if (filterInput) {
filterInput.addEventListener('click', (e) => e.stopPropagation());
let timer = null;
filterInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
this._filterValue = filterInput.value.trim();
this._applyFilter(content, this._filterValue);
}, 150);
});
filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (filterInput.value) {
e.stopPropagation();
filterInput.value = '';
this._filterValue = '';
this._applyFilter(content, '');
}
}
});
// Restore filter from before re-render
if (this._filterValue) {
filterInput.value = this._filterValue;
this._applyFilter(content, this._filterValue);
}
}
}
/** Bind an array of CardSection instances. */
static bindAll(sections) {
for (const s of sections) s.bind();
}
// ── private ──
_toggleCollapse(header, content) {
const map = _getCollapsedMap();
const nowCollapsed = !map[this.sectionKey];
map[this.sectionKey] = nowCollapsed;
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
content.style.display = nowCollapsed ? 'none' : '';
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.textContent = nowCollapsed ? '\u25B6' : '\u25BC';
}
_applyFilter(content, query) {
const lower = query.toLowerCase();
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)');
const addCard = content.querySelector('.cs-add-card');
cards.forEach(card => {
const nameEl = card.querySelector('.card-title') || card.querySelector('.template-name');
if (!nameEl) { card.style.display = ''; return; }
const name = nameEl.textContent.toLowerCase();
card.style.display = (!lower || name.includes(lower)) ? '' : 'none';
});
if (addCard) addCard.style.display = lower ? 'none' : '';
}
}

View File

@@ -7,8 +7,10 @@ import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
const profileModal = new Modal('profile-editor-modal');
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" });
// Re-render profiles when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -53,16 +55,10 @@ export async function loadProfiles() {
function renderProfiles(profiles, runningTargetIds = new Set()) {
const container = document.getElementById('profiles-content');
let html = '<div class="devices-grid">';
for (const p of profiles) {
html += createProfileCard(p, runningTargetIds);
}
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
<div class="add-template-icon">+</div>
</div>`;
html += '</div>';
const cardsHtml = profiles.map(p => createProfileCard(p, runningTargetIds)).join('');
container.innerHTML = csProfiles.render(cardsHtml, profiles.length);
csProfiles.bind();
container.innerHTML = html;
// Localize data-i18n elements within the profiles container only
// (calling global updateAllText() would trigger loadProfiles() again → infinite loop)
container.querySelectorAll('[data-i18n]').forEach(el => {

View File

@@ -26,6 +26,16 @@ import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()" });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')" });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()" });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -605,10 +615,6 @@ function renderPictureSourcesList(streams) {
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
const addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div>
</div>`;
const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', count: rawStreams.length },
@@ -660,73 +666,26 @@ function renderPictureSourcesList(streams) {
let panelContent = '';
if (tab.key === 'raw') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${rawStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('templates.title')}</h3>
<div class="templates-grid">
${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
panelContent =
csRawStreams.render(rawStreams.map(renderStreamCard).join(''), rawStreams.length) +
csRawTemplates.render(_cachedCaptureTemplates.map(renderCaptureTemplateCard).join(''), _cachedCaptureTemplates.length);
} else if (tab.key === 'processed') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${processedStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('postprocessing.title')}</h3>
<div class="templates-grid">
${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
panelContent =
csProcStreams.render(processedStreams.map(renderStreamCard).join(''), processedStreams.length) +
csProcTemplates.render(_cachedPPTemplates.map(renderPPTemplateCard).join(''), _cachedPPTemplates.length);
} else if (tab.key === 'audio') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.multichannel')}</h3>
<div class="templates-grid">
${multichannelSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('multichannel')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.mono')}</h3>
<div class="templates-grid">
${monoSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('mono')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
panelContent =
csAudioMulti.render(multichannelSources.map(renderAudioSourceCard).join(''), multichannelSources.length) +
csAudioMono.render(monoSources.map(renderAudioSourceCard).join(''), monoSources.length);
} else {
panelContent = `
<div class="templates-grid">
${staticImageStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>`;
panelContent = csStaticStreams.render(staticImageStreams.map(renderStreamCard).join(''), staticImageStreams.length);
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams]);
}
export function onStreamTypeChange() {

View File

@@ -15,10 +15,18 @@ import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
import { CardSection } from '../core/card-sections.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
// ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()" });
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()" });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()" });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()" });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()" });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
@@ -425,62 +433,27 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + color strip sources section + targets section
const devicesHtml = ledDevices.map(device => createDeviceCard(device)).join('');
const cssHtml = Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('');
const ledTargetsHtml = ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('');
const kcTargetsHtml = kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('');
const patternTmplHtml = patternTemplates.map(pt => createPatternTemplateCard(pt)).join('');
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid">
${ledDevices.map(device => createDeviceCard(device)).join('')}
<div class="template-card add-template-card" onclick="showAddDevice()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
<div class="devices-grid">
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showCSSEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
${csDevices.render(devicesHtml, ledDevices.length)}
${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)}
${csLedTargets.render(ledTargetsHtml, ledTargets.length)}
</div>`;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
<div class="templates-grid">
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
${csKCTargets.render(kcTargetsHtml, kcTargets.length)}
${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)}
</div>`;
container.innerHTML = tabBar + ledPanel + kcPanel;
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {

View File

@@ -245,6 +245,7 @@
"common.delete": "Delete",
"common.edit": "Edit",
"common.clone": "Clone",
"section.filter.placeholder": "Filter...",
"streams.title": "\uD83D\uDCFA Sources",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Screen Capture",

View File

@@ -245,6 +245,7 @@
"common.delete": "Удалить",
"common.edit": "Редактировать",
"common.clone": "Клонировать",
"section.filter.placeholder": "Фильтр...",
"streams.title": "\uD83D\uDCFA Источники",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана",