diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css
index 4c39916..8518729 100644
--- a/server/src/wled_controller/static/css/streams.css
+++ b/server/src/wled_controller/static/css/streams.css
@@ -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 {
diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js
new file mode 100644
index 0000000..b681a55
--- /dev/null
+++ b/server/src/wled_controller/static/js/core/card-sections.js
@@ -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
+ ? `
`
+ : '';
+
+ return `
+
+
+
+ ${cardsHtml}
+ ${addCard}
+
+
`;
+ }
+
+ /** 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' : '';
+ }
+}
diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js
index 8a78f17..430259b 100644
--- a/server/src/wled_controller/static/js/features/profiles.js
+++ b/server/src/wled_controller/static/js/features/profiles.js
@@ -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 = '';
- for (const p of profiles) {
- html += createProfileCard(p, runningTargetIds);
- }
- html += `
`;
- html += '
';
+ 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 => {
diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js
index 674add4..c0317f4 100644
--- a/server/src/wled_controller/static/js/features/streams.js
+++ b/server/src/wled_controller/static/js/features/streams.js
@@ -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) => `
- `;
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 = `
-
-
-
- ${rawStreams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
-
-
-
-
- ${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
-
-
-
`;
+ panelContent =
+ csRawStreams.render(rawStreams.map(renderStreamCard).join(''), rawStreams.length) +
+ csRawTemplates.render(_cachedCaptureTemplates.map(renderCaptureTemplateCard).join(''), _cachedCaptureTemplates.length);
} else if (tab.key === 'processed') {
- panelContent = `
-
-
-
- ${processedStreams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
-
-
-
-
- ${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
-
-
-
`;
+ panelContent =
+ csProcStreams.render(processedStreams.map(renderStreamCard).join(''), processedStreams.length) +
+ csProcTemplates.render(_cachedPPTemplates.map(renderPPTemplateCard).join(''), _cachedPPTemplates.length);
} else if (tab.key === 'audio') {
- panelContent = `
-
-
-
- ${multichannelSources.map(renderAudioSourceCard).join('')}
-
-
-
-
-
-
- ${monoSources.map(renderAudioSourceCard).join('')}
-
-
-
`;
+ panelContent =
+ csAudioMulti.render(multichannelSources.map(renderAudioSourceCard).join(''), multichannelSources.length) +
+ csAudioMono.render(monoSources.map(renderAudioSourceCard).join(''), monoSources.length);
} else {
- panelContent = `
-
- ${staticImageStreams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
`;
+ panelContent = csStaticStreams.render(staticImageStreams.map(renderStreamCard).join(''), staticImageStreams.length);
}
return `${panelContent}
`;
}).join('');
container.innerHTML = tabBar + panels;
+ CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams]);
}
export function onStreamTypeChange() {
diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js
index 734b0d9..637ad3d 100644
--- a/server/src/wled_controller/static/js/features/targets.js
+++ b/server/src/wled_controller/static/js/features/targets.js
@@ -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 = `
-
-
-
- ${ledDevices.map(device => createDeviceCard(device)).join('')}
-
-
-
-
-
-
- ${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
-
-
-
-
-
-
- ${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
-
-
-
+ ${csDevices.render(devicesHtml, ledDevices.length)}
+ ${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)}
+ ${csLedTargets.render(ledTargetsHtml, ledTargets.length)}
`;
- // Key Colors panel
const kcPanel = `
-
-
-
- ${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
-
-
-
-
-
-
- ${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
-
-
-
+ ${csKCTargets.render(kcTargetsHtml, kcTargets.length)}
+ ${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)}
`;
container.innerHTML = tabBar + ledPanel + kcPanel;
+ CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 15c377b..ddcf526 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -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",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index 6fd4e79..3f43114 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -245,6 +245,7 @@
"common.delete": "Удалить",
"common.edit": "Редактировать",
"common.clone": "Клонировать",
+ "section.filter.placeholder": "Фильтр...",
"streams.title": "\uD83D\uDCFA Источники",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана",