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 ` +
+
+ ${chevron} + ${t(this.titleKey)} + ${count} + +
+
+ ${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 = ` -
-

${t('streams.section.streams')}

-
- ${rawStreams.map(renderStreamCard).join('')} - ${addStreamCard(tab.key)} -
-
-
-

${t('templates.title')}

-
- ${_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 = ` -
-

${t('streams.section.streams')}

-
- ${processedStreams.map(renderStreamCard).join('')} - ${addStreamCard(tab.key)} -
-
-
-

${t('postprocessing.title')}

-
- ${_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 = ` -
-

${t('audio_source.group.multichannel')}

-
- ${multichannelSources.map(renderAudioSourceCard).join('')} -
-
+
-
-
-
-
-

${t('audio_source.group.mono')}

-
- ${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 = `
-
-

${t('targets.section.devices')}

-
- ${ledDevices.map(device => createDeviceCard(device)).join('')} -
-
+
-
-
-
-
-

${t('targets.section.color_strips')}

-
- ${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')} -
-
+
-
-
-
-
-

${t('targets.section.targets')}

-
- ${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 = `
-
-

${t('targets.section.key_colors')}

-
- ${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')} -
-
+
-
-
-
-
-

${t('targets.section.pattern_templates')}

-
- ${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": "Захват Экрана",