fix: device card header layout — URL badge overflow and hide button gap
Lint & Test / test (push) Successful in 1m24s
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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user