diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 7a02860..ce7a1a5 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -76,6 +76,87 @@ section { animation: cardEnter 0.25s ease-out both; } +/* ── Card drag-and-drop reordering ── */ + +.card-drag-handle { + position: absolute; + top: 4px; + left: 4px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + opacity: 0; + color: var(--text-secondary); + font-size: 10px; + letter-spacing: 1px; + line-height: 1; + border-radius: 3px; + transition: opacity 0.2s ease, background 0.15s ease; + z-index: 2; + touch-action: none; + user-select: none; +} + +.card:hover > .card-drag-handle, +.template-card:hover > .card-drag-handle { + opacity: 0.5; +} + +.card-drag-handle:hover { + opacity: 1 !important; + background: var(--border-color); +} + +.card-drag-handle:active { + cursor: grabbing; +} + +/* Clone floating during drag */ +.card-drag-clone { + position: fixed; + z-index: 9999; + pointer-events: none; + opacity: 0.92; + transform: scale(1.03) rotate(0.8deg); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + transition: none; + will-change: left, top; +} + +/* Placeholder in grid */ +.card-drag-placeholder { + border: 2px dashed var(--primary-color); + border-radius: 8px; + background: rgba(33, 150, 243, 0.04); + min-height: 80px; + transition: none; +} + +/* Suppress hover effects during drag */ +.cs-dragging .card, +.cs-dragging .template-card { + transition: none !important; + transform: none !important; +} + +.cs-dragging .card-drag-handle { + opacity: 0 !important; +} + +/* Hide drag handles when filter is active */ +.cs-filtering .card-drag-handle { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .card-drag-clone { + transform: none; + } +} + .card-tutorial-btn { position: absolute; bottom: 10px; diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index 532f4af..75737e7 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -88,6 +88,11 @@ box-shadow: 0 2px 8px var(--shadow-color); } +.dashboard-card-link:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 12px var(--shadow-color); +} + .dashboard-target-info { display: flex; align-items: center; @@ -112,6 +117,10 @@ gap: 4px; } +.dashboard-card-link { + cursor: pointer; +} + .dashboard-target-name .health-dot { margin-right: 0; flex-shrink: 0; diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index 5525766..591ffb1 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -24,6 +24,10 @@ import { t } from './i18n.js'; const STORAGE_KEY = 'sections_collapsed'; +const ORDER_PREFIX = 'card_order_'; +const DRAG_THRESHOLD = 5; +const SCROLL_EDGE = 60; +const SCROLL_SPEED = 12; function _getCollapsedMap() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } @@ -48,6 +52,8 @@ export class CardSection { this.keyAttr = keyAttr || ''; this._filterValue = ''; this._lastItems = null; + this._dragState = null; + this._dragBound = false; } /** True if this section's DOM element exists (i.e. not the first render). */ @@ -61,6 +67,7 @@ export class CardSection { */ render(items) { 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(''); @@ -155,6 +162,12 @@ export class CardSection { // Tag card elements with their source HTML for future reconciliation this._tagCards(content); + // Inject drag handles and initialize drag-and-drop + if (this.keyAttr) { + this._injectDragHandles(content); + this._initDrag(content); + } + // Stagger card entrance animation this._animateEntrance(content); } @@ -232,6 +245,11 @@ export class CardSection { } } + // Re-inject drag handles on new/replaced cards + if (this.keyAttr && (added.size > 0 || replaced.size > 0)) { + this._injectDragHandles(content); + } + // Re-apply filter if (this._filterValue) { this._applyFilter(content, this._filterValue); @@ -292,6 +310,34 @@ export class CardSection { } } + /** + * Reorder items array according to saved drag order. + * Call before render() / reconcile(). + */ + applySortOrder(items) { + if (!this.keyAttr) return items; + const order = this._getSavedOrder(); + if (!order.length) return items; + const orderMap = new Map(order.map((key, idx) => [key, idx])); + const sorted = [...items]; + sorted.sort((a, b) => { + const ia = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity; + const ib = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity; + if (ia !== ib) return ia - ib; + return 0; // preserve original order for unranked items + }); + return sorted; + } + + _getSavedOrder() { + try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; } + catch { return []; } + } + + _saveOrder(keys) { + localStorage.setItem(ORDER_PREFIX + this.sectionKey, JSON.stringify(keys)); + } + // ── private ── _animateEntrance(content) { @@ -368,11 +414,13 @@ export class CardSection { const total = cards.length; if (!query) { + content.classList.remove('cs-filtering'); cards.forEach(card => { card.style.display = ''; }); if (addCard) addCard.style.display = ''; if (countEl) countEl.textContent = total; return; } + content.classList.add('cs-filtering'); const lower = query.toLowerCase(); // Comma-separated segments → AND; spaces within a segment → OR @@ -390,4 +438,166 @@ export class CardSection { if (addCard) addCard.style.display = 'none'; if (countEl) countEl.textContent = `${visible}/${total}`; } + + // ── drag-and-drop reordering ── + + _injectDragHandles(content) { + const cards = content.querySelectorAll(`[${this.keyAttr}]`); + cards.forEach(card => { + if (card.querySelector('.card-drag-handle')) return; + const handle = document.createElement('span'); + handle.className = 'card-drag-handle'; + handle.textContent = '\u2807'; // ⠇ braille dots vertical + handle.setAttribute('aria-hidden', 'true'); + card.prepend(handle); + }); + } + + _initDrag(content) { + if (this._dragBound) return; + this._dragBound = true; + + content.addEventListener('pointerdown', (e) => { + if (this._filterValue) return; + const handle = e.target.closest('.card-drag-handle'); + if (!handle) return; + const card = handle.closest(`[${this.keyAttr}]`); + if (!card) return; + + e.preventDefault(); + this._dragState = { + card, + content, + startX: e.clientX, + startY: e.clientY, + started: false, + clone: null, + placeholder: null, + scrollRaf: null, + }; + + const onMove = (ev) => this._onDragMove(ev); + const onUp = (ev) => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + this._onDragEnd(ev); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }); + } + + _onDragMove(e) { + const ds = this._dragState; + if (!ds) return; + + if (!ds.started) { + const dx = e.clientX - ds.startX; + const dy = e.clientY - ds.startY; + if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return; + this._startDrag(ds, e); + } + + // Position clone at pointer + ds.clone.style.left = (e.clientX - ds.offsetX) + 'px'; + ds.clone.style.top = (e.clientY - ds.offsetY) + 'px'; + + // Find drop target + const target = this._getDropTarget(e.clientX, e.clientY, ds.content); + if (target && target !== ds.placeholder) { + const rect = target.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const midY = rect.top + rect.height / 2; + // For grid: use combined horizontal+vertical check + const insertBefore = (e.clientY < midY) || (e.clientY < midY + rect.height * 0.3 && e.clientX < midX); + if (insertBefore) { + ds.content.insertBefore(ds.placeholder, target); + } else { + ds.content.insertBefore(ds.placeholder, target.nextSibling); + } + } + + // Auto-scroll near viewport edges + this._autoScroll(e.clientY, ds); + } + + _startDrag(ds, e) { + ds.started = true; + const rect = ds.card.getBoundingClientRect(); + + // Clone for visual drag + const clone = ds.card.cloneNode(true); + clone.className = ds.card.className + ' card-drag-clone'; + clone.style.width = rect.width + 'px'; + clone.style.height = rect.height + 'px'; + clone.style.left = rect.left + 'px'; + clone.style.top = rect.top + 'px'; + document.body.appendChild(clone); + ds.clone = clone; + ds.offsetX = e.clientX - rect.left; + ds.offsetY = e.clientY - rect.top; + + // Placeholder + const placeholder = document.createElement('div'); + placeholder.className = 'card-drag-placeholder'; + placeholder.style.width = rect.width + 'px'; + placeholder.style.height = rect.height + 'px'; + ds.card.parentNode.insertBefore(placeholder, ds.card); + ds.placeholder = placeholder; + + // Hide original + ds.card.style.display = 'none'; + ds.content.classList.add('cs-dragging'); + } + + _onDragEnd() { + const ds = this._dragState; + this._dragState = null; + if (!ds || !ds.started) return; + + // Cancel auto-scroll + if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf); + + // Move original card to placeholder position + ds.content.insertBefore(ds.card, ds.placeholder); + ds.card.style.display = ''; + ds.placeholder.remove(); + ds.clone.remove(); + ds.content.classList.remove('cs-dragging'); + + // Save new order from DOM + const keys = this._readDomOrder(ds.content); + this._saveOrder(keys); + } + + _getDropTarget(x, y, content) { + // Temporarily show all cards for hit testing + const els = document.elementsFromPoint(x, y); + for (const el of els) { + if (el === content) break; + const card = el.closest(`[${this.keyAttr}]`); + if (card && card.style.display !== 'none' && content.contains(card)) return card; + } + return null; + } + + _readDomOrder(content) { + return [...content.querySelectorAll(`[${this.keyAttr}]`)] + .map(el => el.getAttribute(this.keyAttr)); + } + + _autoScroll(clientY, ds) { + if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf); + const vp = window.innerHeight; + let speed = 0; + if (clientY < SCROLL_EDGE) speed = -SCROLL_SPEED * (1 - clientY / SCROLL_EDGE); + else if (clientY > vp - SCROLL_EDGE) speed = SCROLL_SPEED * (1 - (vp - clientY) / SCROLL_EDGE); + + if (Math.abs(speed) < 0.5) return; + const scroll = () => { + window.scrollBy(0, speed); + ds.scrollRaf = requestAnimationFrame(scroll); + }; + ds.scrollRaf = requestAnimationFrame(scroll); + } } diff --git a/server/src/wled_controller/static/js/core/navigation.js b/server/src/wled_controller/static/js/core/navigation.js index 64f0d19..7792ca0 100644 --- a/server/src/wled_controller/static/js/core/navigation.js +++ b/server/src/wled_controller/static/js/core/navigation.js @@ -45,8 +45,13 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) { } } + // Scope card search to the destination tab panel (avoid matching + // dashboard cards that share the same data-* attributes). + const tabPanel = document.getElementById(`tab-${tab}`); + const scope = tabPanel || document; + // Check if card already exists (data previously loaded) - const existing = document.querySelector(`[${cardAttr}="${cardValue}"]`); + const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`); if (existing) { _highlightCard(existing); return; @@ -54,17 +59,29 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) { // Card not in DOM — trigger data load and wait for it to appear _triggerTabLoad(tab); - _waitForCard(cardAttr, cardValue, 5000).then(card => { + _waitForCard(cardAttr, cardValue, 5000, scope).then(card => { if (card) _highlightCard(card); }); }); } +let _highlightTimer = 0; +let _overlayTimer = 0; +let _prevCard = null; + function _highlightCard(card) { + // Clear previous highlight if still active + if (_prevCard) _prevCard.classList.remove('card-highlight'); + clearTimeout(_highlightTimer); + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); card.classList.add('card-highlight'); + _prevCard = card; _showDimOverlay(2000); - setTimeout(() => card.classList.remove('card-highlight'), 2000); + _highlightTimer = setTimeout(() => { + card.classList.remove('card-highlight'); + _prevCard = null; + }, 2000); } /** Trigger the tab's data load function (used when card wasn't found in DOM). */ @@ -76,6 +93,7 @@ function _triggerTabLoad(tab) { } function _showDimOverlay(duration) { + clearTimeout(_overlayTimer); let overlay = document.getElementById('nav-dim-overlay'); if (!overlay) { overlay = document.createElement('div'); @@ -84,19 +102,20 @@ function _showDimOverlay(duration) { document.body.appendChild(overlay); } overlay.classList.add('active'); - setTimeout(() => overlay.classList.remove('active'), duration); + _overlayTimer = setTimeout(() => overlay.classList.remove('active'), duration); } -function _waitForCard(cardAttr, cardValue, timeout) { +function _waitForCard(cardAttr, cardValue, timeout, scope = document) { + const root = scope === document ? document.body : scope; return new Promise(resolve => { - const card = document.querySelector(`[${cardAttr}="${cardValue}"]`); + const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`); if (card) { resolve(card); return; } const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); const observer = new MutationObserver(() => { - const el = document.querySelector(`[${cardAttr}="${cardValue}"]`); + const el = scope.querySelector(`[${cardAttr}="${cardValue}"]`); if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); } }); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(root, { childList: true, subtree: true }); }); } diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index b58a520..b617f63 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -447,7 +447,10 @@ export async function loadDashboard(forceFullRender = false) { ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; const subtitle = subtitleParts.length ? `