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

@@ -447,7 +447,10 @@ export async function loadDashboard(forceFullRender = false) {
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
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">
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
<div>
@@ -551,6 +554,10 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = ICON_TARGET;
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];
if (isLed) {
@@ -590,7 +597,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
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">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -620,7 +627,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</div>
</div>`;
} else {
return `<div class="dashboard-target">
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -665,7 +672,7 @@ function renderDashboardProfile(profile) {
const activeCount = (profile.active_target_ids || []).length;
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">
<span class="dashboard-target-icon">${ICON_PROFILE}</span>
<div>

View File

@@ -26,7 +26,7 @@ class ProfileEditorModal extends Modal {
}
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)
document.addEventListener('languageChanged', () => {
@@ -79,7 +79,7 @@ export async function loadProfiles() {
function renderProfiles(profiles, runningTargetIds = new Set()) {
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);
csProfiles.bind();

View File

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

View File

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