Add clone support for all entity types

Clone button on every card opens the editor in create mode pre-filled
with copied data and a "(Copy)" name suffix. Cancelling discards the
clone — entity is only persisted on Save.

Supported: LED targets, color strip sources, KC targets, pattern
templates, picture sources, capture templates, PP templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 15:38:40 +03:00
parent f15ff8fea0
commit cc08bb1c19
8 changed files with 238 additions and 29 deletions

View File

@@ -56,7 +56,7 @@ function getEngineIcon(engineType) {
return '🚀';
}
export async function showAddTemplateModal() {
export async function showAddTemplateModal(cloneData = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
@@ -64,11 +64,20 @@ export async function showAddTemplateModal() {
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
set_templateNameManuallyEdited(false);
set_templateNameManuallyEdited(!!cloneData);
document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('template-description').value = cloneData.description || '';
document.getElementById('template-engine').value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
templateModal.open();
}
@@ -509,6 +518,7 @@ function renderPictureSourcesList(streams) {
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
@@ -544,6 +554,7 @@ function renderPictureSourcesList(streams) {
` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
@@ -566,6 +577,7 @@ function renderPictureSourcesList(streams) {
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
@@ -688,8 +700,8 @@ function _autoGenerateStreamName() {
}
}
export async function showAddStreamModal(presetType) {
const streamType = presetType || 'raw';
export async function showAddStreamModal(presetType, cloneData = null) {
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
document.getElementById('stream-form').reset();
@@ -708,7 +720,7 @@ export async function showAddStreamModal(presetType) {
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
set_streamNameManuallyEdited(false);
set_streamNameManuallyEdited(!!cloneData);
document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); };
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
@@ -716,6 +728,27 @@ export async function showAddStreamModal(presetType) {
await populateStreamModalDropdowns();
// Pre-fill from clone data after dropdowns are populated
if (cloneData) {
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('stream-description').value = cloneData.description || '';
if (streamType === 'raw') {
const displayIdx = cloneData.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
const fps = cloneData.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
document.getElementById('stream-target-fps-value').textContent = fps;
} else if (streamType === 'processed') {
document.getElementById('stream-source').value = cloneData.source_stream_id || '';
document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') {
document.getElementById('stream-image-source').value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
}
}
streamModal.open();
}
@@ -1293,7 +1326,7 @@ function _autoGeneratePPTemplateName() {
}
}
export async function showAddPPTemplateModal() {
export async function showAddPPTemplateModal(cloneData = null) {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
@@ -1301,14 +1334,27 @@ export async function showAddPPTemplateModal() {
document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none';
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
if (cloneData) {
set_modalFilters((cloneData.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
set_ppTemplateNameManuallyEdited(true);
} else {
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
_populateFilterSelect();
renderModalFilterList();
// Pre-fill from clone data after form is set up
if (cloneData) {
document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pp-template-description').value = cloneData.description || '';
}
ppTemplateModal.open();
}
@@ -1374,6 +1420,47 @@ export async function savePPTemplate() {
}
}
// ===== Clone functions =====
export async function cloneStream(streamId) {
try {
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!resp.ok) throw new Error('Failed to load stream');
const stream = await resp.json();
showAddStreamModal(stream.stream_type, stream);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone stream:', error);
showToast('Failed to clone picture source', 'error');
}
}
export async function cloneCaptureTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone capture template:', error);
showToast('Failed to clone capture template', 'error');
}
}
export async function clonePPTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddPPTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone PP template:', error);
showToast('Failed to clone postprocessing template', 'error');
}
}
export async function deletePPTemplate(templateId) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return;