fix: device card header layout — URL badge overflow and hide button gap
Lint & Test / test (push) Successful in 1m24s

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.
This commit is contained in:
2026-03-30 02:05:45 +03:00
parent 384362ccf1
commit 11d5d6b5e1
3 changed files with 238 additions and 14 deletions
@@ -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 */
@@ -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<string, string[]> {
try { return JSON.parse(localStorage.getItem(HIDDEN_KEY) as string) || {}; }
catch { return {}; }
}
function _saveHiddenMap(map: Record<string, string[]>): 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<string, boolean> {
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 `<div class="${gridClass}">${html}</div>`;
}
/** Registry of all CardSection instances for global access. */
const _sectionRegistry = new Map<string, CardSection>();
/** 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 {
? `<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>`
: '';
const emptyState = '';
const hiddenToggle = totalHidden > 0
? `<button type="button" class="cs-hidden-toggle${this._showHidden ? ' active' : ''}" data-cs-hidden-toggle="${this.sectionKey}" title="${t('section.show_hidden')}">${this._showHidden ? ICON_EYE : ICON_EYE_OFF} <span class="cs-hidden-badge">${totalHidden}</span></button>`
: '';
return `
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
@@ -155,6 +212,7 @@ export class CardSection {
<span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span>
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
${hiddenToggle}
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
<div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
@@ -163,7 +221,7 @@ export class CardSection {
</div>
</div>
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
${emptyState}${cardsHtml}
${cardsHtml}
${addCard}
</div>
</div>`;
@@ -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<string>();
const replaced = new Set<string>();
@@ -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} <span class="cs-hidden-badge">${totalHidden}</span>`;
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} <span class="cs-hidden-badge">${totalHidden}</span>`;
}
}
/** 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 => {
@@ -158,12 +158,12 @@ export function createDeviceCard(device: Device & { state?: any }) {
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${openrgbZones.length
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
@@ -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');