Add clone buttons, fix card navigation highlight, UI polish

- Add clone buttons to Audio Source and Value Source cards
- Fix command palette navigation destroying card highlight by skipping
  redundant data reload (skipLoad option on switchTab)
- Convert value source modal sliders to value-in-label pattern
- Change audio/value source modal footers to icon-only buttons
- Remove separator lines between card sections
- Add UI conventions to CLAUDE.md (card appearance, modal footer, sliders)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 03:10:36 +03:00
parent 2b6bc22fc8
commit 466527bd4a
10 changed files with 144 additions and 90 deletions

View File

@@ -107,13 +107,13 @@ import {
// Layer 5: audio sources
import {
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
editAudioSource, cloneAudioSource, deleteAudioSource, onAudioSourceTypeChange,
} from './features/audio-sources.js';
// Layer 5: value sources
import {
showValueSourceModal, closeValueSourceModal, saveValueSource,
editValueSource, deleteValueSource, onValueSourceTypeChange,
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
addSchedulePoint,
} from './features/value-sources.js';
@@ -328,6 +328,7 @@ Object.assign(window, {
closeAudioSourceModal,
saveAudioSource,
editAudioSource,
cloneAudioSource,
deleteAudioSource,
onAudioSourceTypeChange,
@@ -336,6 +337,7 @@ Object.assign(window, {
closeValueSourceModal,
saveValueSource,
editValueSource,
cloneValueSource,
deleteValueSource,
onValueSourceTypeChange,
addSchedulePoint,

View File

@@ -16,7 +16,11 @@ import { switchTab } from '../features/tabs.js';
export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
// Push current location to history so browser back returns here
history.pushState(null, '', location.hash || '#');
switchTab(tab);
// Activate tab visually without triggering a data reload —
// the command palette already fetched fresh data, and a reload
// would re-render all cards, destroying the highlight.
switchTab(tab, { skipLoad: true });
requestAnimationFrame(() => {
if (subTab) {
@@ -41,17 +45,36 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
}
}
// Wait for card to appear in DOM (tab data may load async)
_waitForCard(cardAttr, cardValue, 3000).then(card => {
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('card-highlight');
_showDimOverlay(2000);
setTimeout(() => card.classList.remove('card-highlight'), 2000);
// Check if card already exists (data previously loaded)
const existing = document.querySelector(`[${cardAttr}="${cardValue}"]`);
if (existing) {
_highlightCard(existing);
return;
}
// Card not in DOM — trigger data load and wait for it to appear
_triggerTabLoad(tab);
_waitForCard(cardAttr, cardValue, 5000).then(card => {
if (card) _highlightCard(card);
});
});
}
function _highlightCard(card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('card-highlight');
_showDimOverlay(2000);
setTimeout(() => card.classList.remove('card-highlight'), 2000);
}
/** Trigger the tab's data load function (used when card wasn't found in DOM). */
function _triggerTabLoad(tab) {
if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard();
else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles();
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
}
function _showDimOverlay(duration) {
let overlay = document.getElementById('nav-dim-overlay');
if (!overlay) {

View File

@@ -149,6 +149,22 @@ export async function editAudioSource(sourceId) {
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneAudioSource(sourceId) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showAudioSourceModal(data.source_type, data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteAudioSource(sourceId) {

View File

@@ -748,6 +748,7 @@ function renderPictureSourcesList(streams) {
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>

View File

@@ -20,7 +20,7 @@ function _setHash(tab, subTab) {
let _suppressHashUpdate = false;
export function switchTab(name, { updateHash = true } = {}) {
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
@@ -40,11 +40,11 @@ export function switchTab(name, { updateHash = true } = {}) {
if (name === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
if (!skipLoad && apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
if (!apiKey) return;
if (!apiKey || skipLoad) return;
if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') {

View File

@@ -234,6 +234,22 @@ export async function editValueSource(sourceId) {
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showValueSourceModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteValueSource(sourceId) {
@@ -308,6 +324,7 @@ export function createValueSourceCard(src) {
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>