@@ -398,7 +402,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
${filterChainHtml}
@@ -424,7 +428,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
${filterChainHtml}
@@ -454,6 +458,8 @@ function renderPictureSourcesList(streams: any) {
const audioSourceMap = {};
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
+ const gradients = gradientsCache.data;
+
const tabs = [
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
@@ -463,6 +469,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
+ { key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
@@ -501,6 +508,7 @@ function renderPictureSourcesList(streams: any) {
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
+ { key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
]
},
@@ -553,15 +561,15 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
${propsHtml}
${renderTagChips(src.tags)}
${src.description ? `
${escapeHtml(src.description)}
` : ''}`,
actions: `
-
${ICON_TEST}
-
${ICON_CLONE}
-
${ICON_EDIT} `,
+
${ICON_TEST}
+
${ICON_CLONE}
+
${ICON_EDIT} `,
});
};
@@ -575,7 +583,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
${template.description ? `
${escapeHtml(template.description)}
` : ''}
@@ -607,6 +615,31 @@ function renderPictureSourcesList(streams: any) {
});
};
+ // Gradient card renderer
+ const renderGradientCard = (g: GradientEntity) => {
+ const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
+ const stripPreview = `
`;
+ const lockBadge = g.is_builtin ? `
${t('gradient.builtin')} ` : '';
+ const cloneBtn = `
${ICON_CLONE} `;
+ const editBtn = g.is_builtin ? '' : `
${ICON_EDIT} `;
+ return wrapCard({
+ type: 'template-card',
+ dataAttr: 'data-id',
+ id: g.id,
+ removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
+ removeTitle: t('common.delete'),
+ content: `
+
+ ${stripPreview}
+
+ ${g.stops.length} ${t('gradient.stops_label')}
+
`,
+ actions: `${cloneBtn}${editBtn}`,
+ });
+ };
+
// Build item arrays for all sections
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
@@ -618,6 +651,7 @@ function renderPictureSourcesList(streams: any) {
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
+ const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
@@ -633,6 +667,7 @@ function renderPictureSourcesList(streams: any) {
proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
+ gradients: gradients.length,
audio: _cachedAudioSources.length,
audio_templates: _cachedAudioTemplates.length,
value: _cachedValueSources.length,
@@ -644,6 +679,7 @@ function renderPictureSourcesList(streams: any) {
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
+ csGradients.reconcile(gradientItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
csAudioTemplates.reconcile(audioTemplateItems);
@@ -661,6 +697,7 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
+ else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
@@ -671,7 +708,7 @@ function renderPictureSourcesList(streams: any) {
}).join('');
container.innerHTML = panels;
- CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
+ CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
@@ -687,6 +724,7 @@ function renderPictureSourcesList(streams: any) {
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
+ 'gradients': 'gradients',
'audio-multi': 'audio', 'audio-mono': 'audio',
'audio-templates': 'audio_templates',
'value-sources': 'value',
@@ -1465,7 +1503,7 @@ function _onFilterDragMove(e: any) {
}
// Position clone at pointer
- ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
+ ds.clone!.style.top = (e.clientY - ds.offsetY) + 'px';
// Find drop target by vertical midpoint
const cards = ds.container.querySelectorAll('.pp-filter-card');
@@ -1534,8 +1572,8 @@ function _onFilterDragEnd() {
// Cleanup DOM
ds.card.style.display = '';
- ds.placeholder.remove();
- ds.clone.remove();
+ ds.placeholder!.remove();
+ ds.clone!.remove();
document.body.classList.remove('pp-filter-dragging');
// Reorder filters array
diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts
index fd7de2c..cccb4b2 100644
--- a/server/src/wled_controller/static/js/global.d.ts
+++ b/server/src/wled_controller/static/js/global.d.ts
@@ -252,11 +252,15 @@ interface Window {
mappedAddZone: (...args: any[]) => any;
mappedRemoveZone: (...args: any[]) => any;
onAudioVizChange: (...args: any[]) => any;
- applyGradientPreset: (...args: any[]) => any;
onGradientPresetChange: (...args: any[]) => any;
promptAndSaveGradientPreset: (...args: any[]) => any;
- applyCustomGradientPreset: (...args: any[]) => any;
deleteAndRefreshGradientPreset: (...args: any[]) => any;
+ showGradientModal: (...args: any[]) => any;
+ closeGradientEditor: (...args: any[]) => any;
+ saveGradientEntity: (...args: any[]) => any;
+ cloneGradient: (...args: any[]) => any;
+ editGradient: (...args: any[]) => any;
+ deleteGradient: (...args: any[]) => any;
cloneColorStrip: (...args: any[]) => any;
toggleCSSOverlay: (...args: any[]) => any;
previewCSSFromEditor: (...args: any[]) => any;
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index a6996ad..9b819f7 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -1339,6 +1339,27 @@
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
+ "streams.group.gradients": "Gradients",
+ "gradient.group.title": "Gradients",
+ "gradient.add": "Add Gradient",
+ "gradient.edit": "Edit Gradient",
+ "gradient.builtin": "Built-in",
+ "gradient.stops_label": "stops",
+ "gradient.name": "Name:",
+ "gradient.name.hint": "A descriptive name for this gradient.",
+ "gradient.description": "Description:",
+ "gradient.description.hint": "Optional description for this gradient.",
+ "gradient.created": "Gradient created",
+ "gradient.updated": "Gradient updated",
+ "gradient.cloned": "Gradient cloned",
+ "gradient.deleted": "Gradient deleted",
+ "gradient.error.name_required": "Name is required",
+ "gradient.error.min_stops": "At least 2 color stops are required",
+ "gradient.error.delete_failed": "Failed to delete gradient",
+ "gradient.create_name": "New gradient name:",
+ "gradient.edit_name": "Rename gradient:",
+ "gradient.confirm_delete": "Delete gradient \"{name}\"?",
+ "section.empty.gradients": "No gradients yet",
"tree.group.picture": "Picture Source",
"tree.group.capture": "Screen Capture",
"tree.group.static": "Static",
diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html
index f0969f1..b24218c 100644
--- a/server/src/wled_controller/templates/index.html
+++ b/server/src/wled_controller/templates/index.html
@@ -180,6 +180,7 @@
{% include 'modals/device-settings.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %}
+ {% include 'modals/gradient-editor.html' %}
{% include 'modals/test-css-source.html' %}
{% include 'modals/notification-history.html' %}
{% include 'modals/kc-editor.html' %}
diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html
index 32a8cc2..31172e8 100644
--- a/server/src/wled_controller/templates/modals/css-editor.html
+++ b/server/src/wled_controller/templates/modals/css-editor.html
@@ -212,16 +212,6 @@
-
-