Add dashboard crosslinks and card drag-and-drop reordering

Dashboard cards (targets, auto-start, profiles) are now clickable,
navigating to the full entity card on the appropriate tab. Card
sections support drag-and-drop reordering via grip handles with
localStorage persistence. Fix crosslink navigation scoping to avoid
matching dashboard cards, and fix highlight race on rapid clicks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 00:40:37 +03:00
parent 88abd31c1c
commit 9194b978e0
8 changed files with 364 additions and 38 deletions

View File

@@ -76,6 +76,87 @@ section {
animation: cardEnter 0.25s ease-out both; 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 { .card-tutorial-btn {
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;

View File

@@ -88,6 +88,11 @@
box-shadow: 0 2px 8px var(--shadow-color); 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 { .dashboard-target-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -112,6 +117,10 @@
gap: 4px; gap: 4px;
} }
.dashboard-card-link {
cursor: pointer;
}
.dashboard-target-name .health-dot { .dashboard-target-name .health-dot {
margin-right: 0; margin-right: 0;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -24,6 +24,10 @@
import { t } from './i18n.js'; import { t } from './i18n.js';
const STORAGE_KEY = 'sections_collapsed'; const STORAGE_KEY = 'sections_collapsed';
const ORDER_PREFIX = 'card_order_';
const DRAG_THRESHOLD = 5;
const SCROLL_EDGE = 60;
const SCROLL_SPEED = 12;
function _getCollapsedMap() { function _getCollapsedMap() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
@@ -48,6 +52,8 @@ export class CardSection {
this.keyAttr = keyAttr || ''; this.keyAttr = keyAttr || '';
this._filterValue = ''; this._filterValue = '';
this._lastItems = null; this._lastItems = null;
this._dragState = null;
this._dragBound = false;
} }
/** True if this section's DOM element exists (i.e. not the first render). */ /** True if this section's DOM element exists (i.e. not the first render). */
@@ -61,6 +67,7 @@ export class CardSection {
*/ */
render(items) { render(items) {
this._lastItems = items; this._lastItems = items;
this._dragBound = false; // DOM will be recreated → need to re-init drag
const count = items.length; const count = items.length;
const cardsHtml = items.map(i => i.html).join(''); 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 // Tag card elements with their source HTML for future reconciliation
this._tagCards(content); this._tagCards(content);
// Inject drag handles and initialize drag-and-drop
if (this.keyAttr) {
this._injectDragHandles(content);
this._initDrag(content);
}
// Stagger card entrance animation // Stagger card entrance animation
this._animateEntrance(content); 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 // Re-apply filter
if (this._filterValue) { if (this._filterValue) {
this._applyFilter(content, 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 ── // ── private ──
_animateEntrance(content) { _animateEntrance(content) {
@@ -368,11 +414,13 @@ export class CardSection {
const total = cards.length; const total = cards.length;
if (!query) { if (!query) {
content.classList.remove('cs-filtering');
cards.forEach(card => { card.style.display = ''; }); cards.forEach(card => { card.style.display = ''; });
if (addCard) addCard.style.display = ''; if (addCard) addCard.style.display = '';
if (countEl) countEl.textContent = total; if (countEl) countEl.textContent = total;
return; return;
} }
content.classList.add('cs-filtering');
const lower = query.toLowerCase(); const lower = query.toLowerCase();
// Comma-separated segments → AND; spaces within a segment → OR // Comma-separated segments → AND; spaces within a segment → OR
@@ -390,4 +438,166 @@ export class CardSection {
if (addCard) addCard.style.display = 'none'; if (addCard) addCard.style.display = 'none';
if (countEl) countEl.textContent = `${visible}/${total}`; 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);
}
} }

View File

@@ -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) // Check if card already exists (data previously loaded)
const existing = document.querySelector(`[${cardAttr}="${cardValue}"]`); const existing = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (existing) { if (existing) {
_highlightCard(existing); _highlightCard(existing);
return; 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 // Card not in DOM — trigger data load and wait for it to appear
_triggerTabLoad(tab); _triggerTabLoad(tab);
_waitForCard(cardAttr, cardValue, 5000).then(card => { _waitForCard(cardAttr, cardValue, 5000, scope).then(card => {
if (card) _highlightCard(card); if (card) _highlightCard(card);
}); });
}); });
} }
let _highlightTimer = 0;
let _overlayTimer = 0;
let _prevCard = null;
function _highlightCard(card) { 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.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('card-highlight'); card.classList.add('card-highlight');
_prevCard = card;
_showDimOverlay(2000); _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). */ /** 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) { function _showDimOverlay(duration) {
clearTimeout(_overlayTimer);
let overlay = document.getElementById('nav-dim-overlay'); let overlay = document.getElementById('nav-dim-overlay');
if (!overlay) { if (!overlay) {
overlay = document.createElement('div'); overlay = document.createElement('div');
@@ -84,19 +102,20 @@ function _showDimOverlay(duration) {
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }
overlay.classList.add('active'); 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 => { return new Promise(resolve => {
const card = document.querySelector(`[${cardAttr}="${cardValue}"]`); const card = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (card) { resolve(card); return; } if (card) { resolve(card); return; }
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
const el = document.querySelector(`[${cardAttr}="${cardValue}"]`); const el = scope.querySelector(`[${cardAttr}="${cardValue}"]`);
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); } if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
}); });
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(root, { childList: true, subtree: true });
}); });
} }

View File

@@ -447,7 +447,10 @@ export async function loadDashboard(forceFullRender = false) {
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>` ? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`; : `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''; const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
return `<div class="dashboard-target dashboard-autostart" data-target-id="${target.id}"> const asNavSub = isLed ? 'led' : 'key_colors';
const asNavSec = isLed ? 'led-targets' : 'kc-targets';
const asNavAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-target-id="${target.id}" onclick="if(!event.target.closest('button')){navigateToCard('targets','${asNavSub}','${asNavSec}','${asNavAttr}','${target.id}')}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span> <span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
<div> <div>
@@ -551,6 +554,10 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = ICON_TARGET; const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
const navSubTab = isLed ? 'led' : 'key_colors';
const navSection = isLed ? 'led-targets' : 'kc-targets';
const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`;
let subtitleParts = [typeLabel]; let subtitleParts = [typeLabel];
if (isLed) { if (isLed) {
@@ -590,7 +597,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
healthDot = `<span class="health-dot ${cls}"></span>`; healthDot = `<span class="health-dot ${cls}"></span>`;
} }
return `<div class="dashboard-target" data-target-id="${target.id}"> return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -620,7 +627,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</div> </div>
</div>`; </div>`;
} else { } else {
return `<div class="dashboard-target"> return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -665,7 +672,7 @@ function renderDashboardProfile(profile) {
const activeCount = (profile.active_target_ids || []).length; const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile" data-profile-id="${profile.id}"> return `<div class="dashboard-target dashboard-profile dashboard-card-link" data-profile-id="${profile.id}" onclick="if(!event.target.closest('button')){navigateToCard('profiles',null,'profiles','data-profile-id','${profile.id}')}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_PROFILE}</span> <span class="dashboard-target-icon">${ICON_PROFILE}</span>
<div> <div>

View File

@@ -26,7 +26,7 @@ class ProfileEditorModal extends Modal {
} }
const profileModal = new ProfileEditorModal(); const profileModal = new ProfileEditorModal();
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" }); const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' });
// Re-render profiles when language changes (only if tab is active) // Re-render profiles when language changes (only if tab is active)
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {
@@ -79,7 +79,7 @@ export async function loadProfiles() {
function renderProfiles(profiles, runningTargetIds = new Set()) { function renderProfiles(profiles, runningTargetIds = new Set()) {
const container = document.getElementById('profiles-content'); const container = document.getElementById('profiles-content');
const items = profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })); const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
container.innerHTML = csProfiles.render(items); container.innerHTML = csProfiles.render(items);
csProfiles.bind(); csProfiles.bind();

View File

@@ -43,15 +43,15 @@ import {
} from '../core/icons.js'; } from '../core/icons.js';
// ── Card section instances ── // ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" }); const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()" }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')" }); const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()" }); const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" }); const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()" }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
// Re-render picture sources when language changes // Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1385,21 +1385,21 @@ function renderPictureSourcesList(streams) {
if (tab.key === 'raw') { if (tab.key === 'raw') {
panelContent = panelContent =
csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + csRawStreams.render(csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) +
csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))); csRawTemplates.render(csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))));
} else if (tab.key === 'processed') { } else if (tab.key === 'processed') {
panelContent = panelContent =
csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + csProcStreams.render(csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) +
csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))); csProcTemplates.render(csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))));
} else if (tab.key === 'audio') { } else if (tab.key === 'audio') {
panelContent = panelContent =
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + csAudioMulti.render(csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + csAudioMono.render(csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioTemplates.render(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))); csAudioTemplates.render(csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))));
} else if (tab.key === 'value') { } else if (tab.key === 'value') {
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); panelContent = csValueSources.render(csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))));
} else { } else {
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); panelContent = csStaticStreams.render(csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))));
} }
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`; return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;

View File

@@ -581,12 +581,12 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import // Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// Build items arrays for each section // Build items arrays for each section (apply saved drag order)
const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })); const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })); const cssItems = csColorStrips.applySortOrder(Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })); const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })); const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })); const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init) // Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null; let changedTargetIds = null;