From 11d5d6b5e112966f5cdc93f13992573e6e25c60c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 30 Mar 2026 02:05:45 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20device=20card=20header=20layout=20?= =?UTF-8?q?=E2=80=94=20URL=20badge=20overflow=20and=20hide=20button=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move URL badge from card-title to card-subtitle row to prevent overlap with top-right action buttons. Widen card-header padding-right to 60px for 2-button clearance. Reorder hide button to first position in top-actions so power and trash stay visually adjacent. --- .../src/wled_controller/static/css/cards.css | 68 ++++++- .../static/js/core/card-sections.ts | 180 +++++++++++++++++- .../static/js/features/devices.ts | 4 +- 3 files changed, 238 insertions(+), 14 deletions(-) diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 8d79230..b737b92 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -378,10 +378,39 @@ body.cs-drag-active .card-drag-handle { gap: 2px; } -.card-top-actions .card-remove-btn { +.card-top-actions .card-remove-btn, +.card-top-actions .card-hide-btn { position: static; } +.card-hide-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px; + color: var(--text-secondary); + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} +.card:hover .card-hide-btn, +.template-card:hover .card-hide-btn { + opacity: 0.6; +} +.card-hide-btn:hover { + opacity: 1 !important; + color: var(--text-primary); +} +.card-hide-btn .icon { + width: 14px; + height: 14px; +} + +/* Hidden card dimmed style */ +.cs-card-hidden { + opacity: 0.55; + border-style: dashed !important; +} + .card-actions .color-picker-wrapper, .template-card-actions .color-picker-wrapper { display: flex; @@ -478,7 +507,7 @@ body.cs-drag-active .card-drag-handle { justify-content: space-between; align-items: center; margin-bottom: 10px; - padding-right: 30px; + padding-right: 60px; } .card-title { @@ -1181,6 +1210,41 @@ ul.section-tip li { opacity: 1; } +/* ── Hidden cards toggle ──────────────────────────────────── */ + +.cs-hidden-toggle { + background: none; + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.7rem; + height: 24px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s, border-color 0.2s; + flex-shrink: 0; +} +.cs-hidden-toggle .icon { + width: 14px; + height: 14px; +} +.cs-hidden-toggle:hover { + border-color: var(--primary-color); + color: var(--text-primary); +} +.cs-hidden-toggle.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast, #fff); +} +.cs-hidden-badge { + font-size: 0.65rem; + font-weight: 600; +} + /* ── Bulk selection ────────────────────────────────────────── */ /* Toggle button in section header */ diff --git a/server/src/wled_controller/static/js/core/card-sections.ts b/server/src/wled_controller/static/js/core/card-sections.ts index 07b661e..a62eb0a 100644 --- a/server/src/wled_controller/static/js/core/card-sections.ts +++ b/server/src/wled_controller/static/js/core/card-sections.ts @@ -23,7 +23,7 @@ import { t } from './i18n.ts'; import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts'; -import { ICON_LIST_CHECKS } from './icons.ts'; +import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF } from './icons.ts'; export interface BulkAction { key: string; @@ -52,11 +52,44 @@ export interface CardSectionOpts { const STORAGE_KEY = 'sections_collapsed'; const ORDER_PREFIX = 'card_order_'; +const HIDDEN_KEY = 'hidden_cards'; const DRAG_THRESHOLD = 5; const SCROLL_EDGE = 60; const SCROLL_SPEED = 12; const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); +function _getHiddenMap(): Record { + try { return JSON.parse(localStorage.getItem(HIDDEN_KEY) as string) || {}; } + catch { return {}; } +} + +function _saveHiddenMap(map: Record): void { + localStorage.setItem(HIDDEN_KEY, JSON.stringify(map)); +} + +/** Get hidden IDs for a section. */ +export function getHiddenIds(sectionKey: string): string[] { + return _getHiddenMap()[sectionKey] || []; +} + +/** Hide a card in a section. */ +export function hideCard(sectionKey: string, id: string): void { + const map = _getHiddenMap(); + const ids = map[sectionKey] || []; + if (!ids.includes(id)) { + map[sectionKey] = [...ids, id]; + _saveHiddenMap(map); + } +} + +/** Unhide a card in a section. */ +export function unhideCard(sectionKey: string, id: string): void { + const map = _getHiddenMap(); + const ids = map[sectionKey] || []; + map[sectionKey] = ids.filter(x => x !== id); + _saveHiddenMap(map); +} + function _getCollapsedMap(): Record { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) as string) || {}; } catch { return {}; } @@ -80,6 +113,15 @@ export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') { return `
${html}
`; } +/** Registry of all CardSection instances for global access. */ +const _sectionRegistry = new Map(); + +/** Toggle hidden state of a card from any context (e.g. inline onclick). */ +export function toggleCardHidden(sectionKey: string, id: string): void { + const section = _sectionRegistry.get(sectionKey); + if (section) section.toggleHidden(id); +} + export class CardSection { sectionKey: string; @@ -101,6 +143,7 @@ export class CardSection { _escHandler: ((e: KeyboardEvent) => void) | null; _pendingReconcile: CardItem[] | null; _animated: boolean; + _showHidden: boolean; constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) { this.sectionKey = sectionKey; @@ -123,6 +166,8 @@ export class CardSection { this._escHandler = null; this._pendingReconcile = null; this._animated = false; + this._showHidden = false; + _sectionRegistry.set(sectionKey, this); } /** True if this section's DOM element exists (i.e. not the first render). */ @@ -134,8 +179,18 @@ export class CardSection { render(items: CardItem[]) { this._lastItems = items; this._dragBound = false; // DOM will be recreated → need to re-init drag - const count = items.length; - const cardsHtml = items.map(i => i.html).join(''); + + const hiddenIds = new Set(getHiddenIds(this.sectionKey)); + const visibleItems = this._showHidden ? items : items.filter(i => !hiddenIds.has(i.key)); + const hiddenCount = items.length - visibleItems.length + (this._showHidden ? 0 : 0); + const totalHidden = items.filter(i => hiddenIds.has(i.key)).length; + const count = visibleItems.length; + const cardsHtml = visibleItems.map(i => { + if (hiddenIds.has(i.key)) { + return i.html.replace(/class="(card|template-card)/, 'class="$1 cs-card-hidden'); + } + return i.html; + }).join(''); const isCollapsed = this.collapsible && !!_getCollapsedMap()[this.sectionKey]; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; @@ -146,7 +201,9 @@ export class CardSection { ? `
+
` : ''; - const emptyState = ''; + const hiddenToggle = totalHidden > 0 + ? `` + : ''; return `
@@ -155,6 +212,7 @@ export class CardSection { ${t(this.titleKey)} ${count} ${this.headerExtra ? `${this.headerExtra}` : ''} + ${hiddenToggle} ${this.bulkActions ? `` : ''}
- ${emptyState}${cardsHtml} + ${cardsHtml} ${addCard}
`; @@ -232,6 +290,20 @@ export class CardSection { updateResetVisibility(); } + // Hidden cards toggle button + const hiddenToggleBtn = document.querySelector(`[data-cs-hidden-toggle="${this.sectionKey}"]`); + if (hiddenToggleBtn) { + hiddenToggleBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + hiddenToggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this._showHidden = !this._showHidden; + // Re-render by triggering reconcile with last items + if (this._lastItems) this.reconcile(this._lastItems); + // Update toggle button visual + this._updateHiddenToggle(); + }); + } + // Bulk selection toggle button if (this.bulkActions) { const bulkBtn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`); @@ -282,8 +354,9 @@ export class CardSection { // Tag card elements with their source HTML for future reconciliation this._tagCards(content); - // Inject drag handles and initialize drag-and-drop + // Inject hide buttons and drag handles, initialize drag-and-drop if (this.keyAttr) { + this._injectHideButtons(content); this._injectDragHandles(content); this._initDrag(content); } @@ -308,15 +381,30 @@ export class CardSection { this._lastItems = items; + // Filter hidden items + const hiddenIds = new Set(getHiddenIds(this.sectionKey)); + const visibleItems = this._showHidden ? items : items.filter(i => !hiddenIds.has(i.key)); + // Update count badge (will be refined by _applyFilter if a filter is active) const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`); - if (countEl && !this._filterValue) countEl.textContent = String(items.length); + if (countEl && !this._filterValue) countEl.textContent = String(visibleItems.length); + + // Update hidden toggle button + this._updateHiddenToggle(); // Remove any stale empty-state element from DOM const staleEmpty = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`); if (staleEmpty) staleEmpty.remove(); - const newMap = new Map(items.map(i => [i.key, i.html])); + // Mark hidden cards with CSS class in HTML + const processedItems = visibleItems.map(i => { + if (hiddenIds.has(i.key)) { + return { key: i.key, html: i.html.replace(/class="(card|template-card)/, 'class="$1 cs-card-hidden') }; + } + return i; + }); + + const newMap = new Map(processedItems.map(i => [i.key, i.html])); const addCard = content.querySelector('.cs-add-card'); const added = new Set(); const replaced = new Set(); @@ -348,7 +436,7 @@ export class CardSection { const existingKeys = new Set([...content.querySelectorAll(`[${this.keyAttr}]`)].map( el => el.getAttribute(this.keyAttr) )); - for (const { key, html } of items) { + for (const { key, html } of processedItems) { if (!existingKeys.has(key)) { const tmp = document.createElement('div'); tmp.innerHTML = html; @@ -375,8 +463,9 @@ export class CardSection { } } - // Re-inject drag handles on new/replaced cards + // Re-inject hide buttons and drag handles on new/replaced cards if (this.keyAttr && (added.size > 0 || replaced.size > 0)) { + this._injectHideButtons(content); this._injectDragHandles(content); } @@ -700,8 +789,79 @@ export class CardSection { if (countEl) countEl.textContent = `${visible}/${total}`; } + /** Update the hidden toggle button's visual state. */ + _updateHiddenToggle() { + const btn = document.querySelector(`[data-cs-hidden-toggle="${this.sectionKey}"]`) as HTMLElement | null; + const totalHidden = this._lastItems ? this._lastItems.filter(i => getHiddenIds(this.sectionKey).includes(i.key)).length : 0; + + if (totalHidden === 0 && btn) { + btn.remove(); + return; + } + + if (totalHidden > 0 && !btn) { + // Insert toggle button into header + const bulkBtn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`); + const filterWrap = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-filter-wrap`); + const insertBefore = bulkBtn || filterWrap; + if (insertBefore) { + const newBtn = document.createElement('button'); + newBtn.type = 'button'; + newBtn.className = `cs-hidden-toggle${this._showHidden ? ' active' : ''}`; + newBtn.dataset.csHiddenToggle = this.sectionKey; + newBtn.title = t('section.show_hidden'); + newBtn.innerHTML = `${this._showHidden ? ICON_EYE : ICON_EYE_OFF} ${totalHidden}`; + newBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + newBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this._showHidden = !this._showHidden; + if (this._lastItems) this.reconcile(this._lastItems); + this._updateHiddenToggle(); + }); + insertBefore.parentNode!.insertBefore(newBtn, insertBefore); + } + return; + } + + if (btn) { + btn.classList.toggle('active', this._showHidden); + btn.innerHTML = `${this._showHidden ? ICON_EYE : ICON_EYE_OFF} ${totalHidden}`; + } + } + + /** Toggle hidden state for a card and re-render. */ + toggleHidden(id: string) { + const hidden = getHiddenIds(this.sectionKey); + if (hidden.includes(id)) { + unhideCard(this.sectionKey, id); + } else { + hideCard(this.sectionKey, id); + } + if (this._lastItems) this.reconcile(this._lastItems); + } + // ── drag-and-drop reordering ── + _injectHideButtons(content: HTMLElement) { + if (!this.keyAttr) return; + content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => { + if (card.querySelector('.card-hide-btn')) return; + const topActions = card.querySelector('.card-top-actions'); + if (!topActions) return; + const key = card.getAttribute(this.keyAttr)!; + const btn = document.createElement('button'); + btn.className = 'card-hide-btn'; + btn.title = 'Hide'; + btn.innerHTML = ICON_EYE_OFF; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleHidden(key); + }); + // Insert as first child so order is [hide] [power] [trash] + topActions.insertBefore(btn, topActions.firstChild); + }); + } + _injectDragHandles(content: HTMLElement) { const cards = content.querySelectorAll(`[${this.keyAttr}]`); cards.forEach(card => { diff --git a/server/src/wled_controller/static/js/features/devices.ts b/server/src/wled_controller/static/js/features/devices.ts index f6cd56e..28c0f63 100644 --- a/server/src/wled_controller/static/js/features/devices.ts +++ b/server/src/wled_controller/static/js/features/devices.ts @@ -158,12 +158,12 @@ export function createDeviceCard(device: Device & { state?: any }) {
${device.name || device.id} - ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel}
${(device.device_type || 'wled').toUpperCase()} + ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${openrgbZones.length ? openrgbZones.map((z: any) => `${ICON_LED} ${escapeHtml(z)}`).join('') : (ledCount ? `${ICON_LED} ${ledCount}` : '')} @@ -200,7 +200,7 @@ export async function turnOffDevice(deviceId: any) { try { const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, { method: 'PUT', - body: JSON.stringify({ on: false }) + body: JSON.stringify({ power: false }) }); if (setResp.ok) { showToast(t('device.power.off_success'), 'success');