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

@@ -53,12 +53,14 @@ import {
showAddPPTemplateModal, editPPTemplate, closePPTemplateModal, savePPTemplate, deletePPTemplate, showAddPPTemplateModal, editPPTemplate, closePPTemplateModal, savePPTemplate, deletePPTemplate,
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption, addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
renderModalFilterList, updateCaptureDuration, renderModalFilterList, updateCaptureDuration,
cloneStream, cloneCaptureTemplate, clonePPTemplate,
} from './features/streams.js'; } from './features/streams.js';
import { import {
createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh, createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor, showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets, deleteKCTarget, disconnectAllKCWebSockets,
updateKCBrightnessLabel, saveKCBrightness, updateKCBrightnessLabel, saveKCBrightness,
cloneKCTarget,
} from './features/kc-targets.js'; } from './features/kc-targets.js';
import { import {
createPatternTemplateCard, createPatternTemplateCard,
@@ -67,6 +69,7 @@ import {
renderPatternRectList, selectPatternRect, updatePatternRect, renderPatternRectList, selectPatternRect, updatePatternRect,
addPatternRect, deleteSelectedPatternRect, removePatternRect, addPatternRect, deleteSelectedPatternRect, removePatternRect,
capturePatternBackground, capturePatternBackground,
clonePatternTemplate,
} from './features/pattern-templates.js'; } from './features/pattern-templates.js';
import { import {
loadProfiles, openProfileEditor, closeProfileEditorModal, loadProfiles, openProfileEditor, closeProfileEditorModal,
@@ -85,6 +88,7 @@ import {
addTargetSegment, removeTargetSegment, addTargetSegment, removeTargetSegment,
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget,
} from './features/targets.js'; } from './features/targets.js';
// Layer 5: color-strip sources // Layer 5: color-strip sources
@@ -95,6 +99,7 @@ import {
compositeAddLayer, compositeRemoveLayer, compositeAddLayer, compositeRemoveLayer,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
cloneColorStrip,
} from './features/color-strips.js'; } from './features/color-strips.js';
// Layer 5: calibration // Layer 5: calibration
@@ -210,6 +215,9 @@ Object.assign(window, {
moveFilter, moveFilter,
updateFilterOption, updateFilterOption,
renderModalFilterList, renderModalFilterList,
cloneStream,
cloneCaptureTemplate,
clonePPTemplate,
// kc-targets // kc-targets
createKCTargetCard, createKCTargetCard,
@@ -223,6 +231,7 @@ Object.assign(window, {
disconnectAllKCWebSockets, disconnectAllKCWebSockets,
updateKCBrightnessLabel, updateKCBrightnessLabel,
saveKCBrightness, saveKCBrightness,
cloneKCTarget,
// pattern-templates // pattern-templates
createPatternTemplateCard, createPatternTemplateCard,
@@ -238,6 +247,7 @@ Object.assign(window, {
deleteSelectedPatternRect, deleteSelectedPatternRect,
removePatternRect, removePatternRect,
capturePatternBackground, capturePatternBackground,
clonePatternTemplate,
// profiles // profiles
loadProfiles, loadProfiles,
@@ -273,6 +283,7 @@ Object.assign(window, {
startTargetOverlay, startTargetOverlay,
stopTargetOverlay, stopTargetOverlay,
deleteTarget, deleteTarget,
cloneTarget,
// color-strip sources // color-strip sources
showCSSEditor, showCSSEditor,
@@ -290,6 +301,7 @@ Object.assign(window, {
compositeRemoveLayer, compositeRemoveLayer,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
cloneColorStrip,
// calibration // calibration
showCalibration, showCalibration,

View File

@@ -584,6 +584,7 @@ export function createColorStripCard(source, pictureSourceMap) {
${propsHtml} ${propsHtml}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button> <button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
${calibrationBtn} ${calibrationBtn}
</div> </div>
@@ -593,7 +594,7 @@ export function createColorStripCard(source, pictureSourceMap) {
/* ── Editor open/close ────────────────────────────────────────── */ /* ── Editor open/close ────────────────────────────────────────── */
export async function showCSSEditor(cssId = null) { export async function showCSSEditor(cssId = null, cloneData = null) {
try { try {
const sourcesResp = await fetchWithAuth('/picture-sources'); const sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : []; const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
@@ -616,14 +617,8 @@ export async function showCSSEditor(cssId = null) {
sourceSelect.appendChild(opt); sourceSelect.appendChild(opt);
}); });
if (cssId) { // Helper: populate editor fields from a CSS source object
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); const _populateFromCSS = async (css) => {
if (!resp.ok) throw new Error('Failed to load color strip source');
const css = await resp.json();
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
const sourceType = css.source_type || 'picture'; const sourceType = css.source_type || 'picture';
document.getElementById('css-editor-type').value = sourceType; document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange(); onCSSTypeChange();
@@ -656,10 +651,6 @@ export async function showCSSEditor(cssId = null) {
await _loadAudioDevices(); await _loadAudioDevices();
_loadAudioState(css); _loadAudioState(css);
} else if (sourceType === 'composite') { } else if (sourceType === 'composite') {
// Exclude self from available sources when editing
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && s.id !== css.id
);
_loadCompositeState(css); _loadCompositeState(css);
} else { } else {
sourceSelect.value = css.picture_source_id || ''; sourceSelect.value = css.picture_source_id || '';
@@ -686,7 +677,30 @@ export async function showCSSEditor(cssId = null) {
} }
document.getElementById('css-editor-led-count').value = css.led_count ?? 0; document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
};
if (cssId) {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
if (!resp.ok) throw new Error('Failed to load color strip source');
const css = await resp.json();
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
// Exclude self from composite sources when editing
if (css.source_type === 'composite') {
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && s.id !== css.id
);
}
await _populateFromCSS(css);
document.getElementById('css-editor-title').textContent = t('color_strip.edit'); document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else if (cloneData) {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = (cloneData.name || '') + ' (Copy)';
await _populateFromCSS(cloneData);
document.getElementById('css-editor-title').textContent = t('color_strip.add');
} else { } else {
document.getElementById('css-editor-id').value = ''; document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = ''; document.getElementById('css-editor-name').value = '';
@@ -882,6 +896,21 @@ export async function saveCSSEditor() {
} }
} }
/* ── Clone ────────────────────────────────────────────────────── */
export async function cloneColorStrip(cssId) {
try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
if (!resp.ok) throw new Error('Failed to load color strip source');
const css = await resp.json();
showCSSEditor(null, css);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone color strip:', error);
showToast('Failed to clone color strip source', 'error');
}
}
/* ── Delete ───────────────────────────────────────────────────── */ /* ── Delete ───────────────────────────────────────────────────── */
export async function deleteColorStrip(cssId) { export async function deleteColorStrip(cssId) {

View File

@@ -148,6 +148,9 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}"> <button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
🧪 🧪
</button> </button>
<button class="btn btn-icon btn-secondary" onclick="cloneKCTarget('${target.id}')" title="${t('common.clone')}">
📋
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}"> <button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️ ✏️
</button> </button>
@@ -345,7 +348,7 @@ function _autoGenerateKCName() {
document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`; document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`;
} }
export async function showKCEditor(targetId = null) { export async function showKCEditor(targetId = null, cloneData = null) {
try { try {
// Load sources and pattern templates in parallel // Load sources and pattern templates in parallel
const [sourcesResp, patResp] = await Promise.all([ const [sourcesResp, patResp] = await Promise.all([
@@ -395,6 +398,18 @@ export async function showKCEditor(targetId = null) {
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || ''; patSelect.value = kcSettings.pattern_template_id || '';
document.getElementById('kc-editor-title').textContent = t('kc.edit'); document.getElementById('kc-editor-title').textContent = t('kc.edit');
} else if (cloneData) {
const kcSettings = cloneData.key_colors_settings || {};
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)';
sourceSelect.value = cloneData.picture_source_id || '';
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || '';
document.getElementById('kc-editor-title').textContent = t('kc.add');
} else { } else {
document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = ''; document.getElementById('kc-editor-name').value = '';
@@ -409,12 +424,12 @@ export async function showKCEditor(targetId = null) {
} }
// Auto-name // Auto-name
set_kcNameManuallyEdited(!!targetId); set_kcNameManuallyEdited(!!(targetId || cloneData));
document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); }; document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); };
sourceSelect.onchange = () => _autoGenerateKCName(); sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName(); document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
patSelect.onchange = () => _autoGenerateKCName(); patSelect.onchange = () => _autoGenerateKCName();
if (!targetId) _autoGenerateKCName(); if (!targetId && !cloneData) _autoGenerateKCName();
kcEditorModal.snapshot(); kcEditorModal.snapshot();
kcEditorModal.open(); kcEditorModal.open();
@@ -502,6 +517,18 @@ export async function saveKCEditor() {
} }
} }
export async function cloneKCTarget(targetId) {
try {
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
showKCEditor(null, target);
} catch (error) {
console.error('Failed to clone KC target:', error);
showToast('Failed to clone key colors target', 'error');
}
}
export async function deleteKCTarget(targetId) { export async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm')); const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;

View File

@@ -62,13 +62,14 @@ export function createPatternTemplateCard(pt) {
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span> <span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div> </div>
<div class="template-card-actions"> <div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button> <button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button>
</div> </div>
</div> </div>
`; `;
} }
export async function showPatternTemplateEditor(templateId = null) { export async function showPatternTemplateEditor(templateId = null, cloneData = null) {
try { try {
// Load sources for background capture // Load sources for background capture
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null); const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
@@ -98,6 +99,12 @@ export async function showPatternTemplateEditor(templateId = null) {
document.getElementById('pattern-template-description').value = tmpl.description || ''; document.getElementById('pattern-template-description').value = tmpl.description || '';
document.getElementById('pattern-template-modal-title').textContent = t('pattern.edit'); document.getElementById('pattern-template-modal-title').textContent = t('pattern.edit');
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
} else if (cloneData) {
document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pattern-template-description').value = cloneData.description || '';
document.getElementById('pattern-template-modal-title').textContent = t('pattern.add');
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
} else { } else {
document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = ''; document.getElementById('pattern-template-name').value = '';
@@ -180,6 +187,18 @@ export async function savePatternTemplate() {
} }
} }
export async function clonePatternTemplate(templateId) {
try {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load pattern template');
const tmpl = await resp.json();
showPatternTemplateEditor(null, tmpl);
} catch (error) {
console.error('Failed to clone pattern template:', error);
showToast('Failed to clone pattern template', 'error');
}
}
export async function deletePatternTemplate(templateId) { export async function deletePatternTemplate(templateId) {
const confirmed = await showConfirm(t('pattern.delete.confirm')); const confirmed = await showConfirm(t('pattern.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;

View File

@@ -56,7 +56,7 @@ function getEngineIcon(engineType) {
return '🚀'; return '🚀';
} }
export async function showAddTemplateModal() { export async function showAddTemplateModal(cloneData = null) {
setCurrentEditingTemplateId(null); setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title').textContent = t('templates.add'); document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset(); document.getElementById('template-form').reset();
@@ -64,11 +64,20 @@ export async function showAddTemplateModal() {
document.getElementById('engine-config-section').style.display = 'none'; document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none'; document.getElementById('template-error').style.display = 'none';
set_templateNameManuallyEdited(false); set_templateNameManuallyEdited(!!cloneData);
document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); }; document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines(); 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(); templateModal.open();
} }
@@ -509,6 +518,7 @@ function renderPictureSourcesList(streams) {
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''} ${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions"> <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="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> <button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">✏️</button>
</div> </div>
</div> </div>
@@ -544,6 +554,7 @@ function renderPictureSourcesList(streams) {
` : ''} ` : ''}
<div class="template-card-actions"> <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="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> <button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">✏️</button>
</div> </div>
</div> </div>
@@ -566,6 +577,7 @@ function renderPictureSourcesList(streams) {
${filterChainHtml} ${filterChainHtml}
<div class="template-card-actions"> <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="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> <button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">✏️</button>
</div> </div>
</div> </div>
@@ -688,8 +700,8 @@ function _autoGenerateStreamName() {
} }
} }
export async function showAddStreamModal(presetType) { export async function showAddStreamModal(presetType, cloneData = null) {
const streamType = presetType || 'raw'; const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; 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-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
document.getElementById('stream-form').reset(); document.getElementById('stream-form').reset();
@@ -708,7 +720,7 @@ export async function showAddStreamModal(presetType) {
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange(); onStreamTypeChange();
set_streamNameManuallyEdited(false); set_streamNameManuallyEdited(!!cloneData);
document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); }; document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); };
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
@@ -716,6 +728,27 @@ export async function showAddStreamModal(presetType) {
await populateStreamModalDropdowns(); 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(); streamModal.open();
} }
@@ -1293,7 +1326,7 @@ function _autoGeneratePPTemplateName() {
} }
} }
export async function showAddPPTemplateModal() { export async function showAddPPTemplateModal(cloneData = null) {
if (_availableFilters.length === 0) await loadAvailableFilters(); if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); 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-id').value = '';
document.getElementById('pp-template-error').style.display = 'none'; document.getElementById('pp-template-error').style.display = 'none';
set_modalFilters([]); if (cloneData) {
set_modalFilters((cloneData.filters || []).map(fi => ({
set_ppTemplateNameManuallyEdited(false); 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); }; document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
_populateFilterSelect(); _populateFilterSelect();
renderModalFilterList(); 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(); 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) { export async function deletePPTemplate(templateId) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm')); const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;

View File

@@ -210,7 +210,7 @@ function _renderSegmentRowInner(index, segment) {
`; `;
} }
export async function showTargetEditor(targetId = null) { export async function showTargetEditor(targetId = null, cloneData = null) {
try { try {
// Load devices and CSS sources for dropdowns // Load devices and CSS sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([ const [devicesResp, cssResp] = await Promise.all([
@@ -263,6 +263,24 @@ export async function showTargetEditor(targetId = null) {
} else { } else {
segments.forEach(seg => addTargetSegment(seg)); segments.forEach(seg => addTargetSegment(seg));
} }
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || '';
const fps = cloneData.fps ?? 30;
document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.add');
const segments = cloneData.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
} else { } else {
// Creating new target — start with one empty segment // Creating new target — start with one empty segment
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
@@ -276,11 +294,11 @@ export async function showTargetEditor(targetId = null) {
} }
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!targetId; _targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName; window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
if (!targetId) _autoGenerateTargetName(); if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities // Show/hide standby interval based on selected device capabilities
_updateDeviceInfo(); _updateDeviceInfo();
@@ -735,6 +753,9 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
▶️ ▶️
</button> </button>
`} `}
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
📋
</button>
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}"> <button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️ ✏️
</button> </button>
@@ -819,6 +840,18 @@ export async function stopTargetOverlay(targetId) {
}); });
} }
export async function cloneTarget(targetId) {
try {
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
showTargetEditor(null, target);
} catch (error) {
console.error('Failed to clone target:', error);
showToast('Failed to clone target', 'error');
}
}
export async function deleteTarget(targetId) { export async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm')); const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;

View File

@@ -237,6 +237,7 @@
"common.loading": "Loading...", "common.loading": "Loading...",
"common.delete": "Delete", "common.delete": "Delete",
"common.edit": "Edit", "common.edit": "Edit",
"common.clone": "Clone",
"streams.title": "\uD83D\uDCFA Sources", "streams.title": "\uD83D\uDCFA Sources",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Screen Capture", "streams.group.raw": "Screen Capture",

View File

@@ -237,6 +237,7 @@
"common.loading": "Загрузка...", "common.loading": "Загрузка...",
"common.delete": "Удалить", "common.delete": "Удалить",
"common.edit": "Редактировать", "common.edit": "Редактировать",
"common.clone": "Клонировать",
"streams.title": "\uD83D\uDCFA Источники", "streams.title": "\uD83D\uDCFA Источники",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана", "streams.group.raw": "Захват Экрана",