diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 6e16407..2f86ccc 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -480,13 +480,11 @@ body.cs-drag-active .card-drag-handle { .card-title { font-size: 1.05rem; font-weight: 600; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; min-width: 0; display: flex; align-items: center; gap: 8px; + overflow: hidden; } .card-title-text { @@ -500,6 +498,7 @@ body.cs-drag-active .card-drag-handle { color: var(--primary-text-color); vertical-align: middle; margin-right: 6px; + flex-shrink: 0; } .device-url-badge { diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index a8096f8..907354f 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -580,6 +580,10 @@ margin: 0; font-size: 1.5rem; color: var(--text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; } .modal-header-actions { @@ -1214,7 +1218,8 @@ user-select: none; } -#gradient-canvas { +#gradient-canvas, +#ge-gradient-canvas { width: 100%; height: 44px; display: block; diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 95c7a06..b2efba4 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -67,6 +67,8 @@ justify-content: space-between; align-items: center; margin-bottom: 12px; + overflow: hidden; + min-width: 0; } .template-card .template-card-header { @@ -81,6 +83,7 @@ white-space: nowrap; text-overflow: ellipsis; min-width: 0; + max-width: 100%; } .template-name > .icon { diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index fb16390..dc474ae 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -123,11 +123,15 @@ import { compositeAddLayer, compositeRemoveLayer, mappedAddZone, mappedRemoveZone, onAudioVizChange, - applyGradientPreset, onGradientPresetChange, promptAndSaveGradientPreset, - applyCustomGradientPreset, deleteAndRefreshGradientPreset, + showGradientModal, + closeGradientEditor, + saveGradientEntity, + cloneGradient, + editGradient, + deleteGradient, cloneColorStrip, toggleCSSOverlay, previewCSSFromEditor, @@ -441,11 +445,15 @@ Object.assign(window, { mappedAddZone, mappedRemoveZone, onAudioVizChange, - applyGradientPreset, onGradientPresetChange, promptAndSaveGradientPreset, - applyCustomGradientPreset, deleteAndRefreshGradientPreset, + showGradientModal, + closeGradientEditor, + saveGradientEntity, + cloneGradient, + editGradient, + deleteGradient, cloneColorStrip, toggleCSSOverlay, previewCSSFromEditor, diff --git a/server/src/wled_controller/static/js/core/card-colors.ts b/server/src/wled_controller/static/js/core/card-colors.ts index 5ee4bce..ec99fb0 100644 --- a/server/src/wled_controller/static/js/core/card-colors.ts +++ b/server/src/wled_controller/static/js/core/card-colors.ts @@ -115,7 +115,7 @@ export function wrapCard({
${topButtons} - + ${removeOnclick ? `` : ''}
${content}
diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index a01fd15..931d3c5 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -330,3 +330,17 @@ export const scenePresetsCache = new DataCache({ endpoint: '/scene-presets', extractData: json => json.presets || [], }); + +export interface GradientEntity { + id: string; + name: string; + stops: Array<{ position: number; color: number[] }>; + is_builtin: boolean; + description?: string; + tags: string[]; +} + +export const gradientsCache = new DataCache({ + endpoint: '/gradients', + extractData: json => json.gradients || [], +}); diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index bb63bd5..dcf80ac 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -3,7 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; -import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.ts'; +import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, GradientEntity } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -24,9 +24,8 @@ import { EntitySelect } from '../core/entity-palette.ts'; import { getBaseOrigin } from './settings.ts'; import { rgbArrayToHex, hexToRgbArray, - gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset, - getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML, - loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset, + gradientInit, gradientRenderAll, gradientAddStop, + getGradientStops, gradientSetIdPrefix, } from './css-gradient-editor.ts'; import { compositeDestroyEntitySelects, compositeSetAvailableSources, compositeGetAvailableSources, @@ -42,8 +41,7 @@ import { } from './color-strips-notification.ts'; // Re-export for app.js window global bindings -export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset }; -export { saveCurrentAsCustomPreset, deleteCustomGradientPreset }; +export { gradientInit, gradientRenderAll, gradientAddStop }; export { compositeAddLayer, compositeRemoveLayer }; export { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, @@ -351,20 +349,6 @@ function _syncDaylightSpeedVisibility() { /* ── Gradient strip preview helper ────────────────────────────── */ -/** - * Build a small inline CSS gradient preview from palette color points. - * @param {Array<[number, string]>} pts – [[position, 'r,g,b'], ...] - * @param {number} [w=80] width in px - * @param {number} [h=16] height in px - * @returns {string} HTML string - */ -function _gradientStripHTML(pts: any[], w = 80, h = 16) { - const stops = pts.map(([pos, rgb]) => `rgb(${rgb}) ${(pos * 100).toFixed(0)}%`).join(', '); - return ``; -} - -/* gradientPresetStripHTML imported from css-gradient-editor.js */ - /* ── Effect / audio palette IconSelect instances ─────────────── */ let _animationTypeIconSelect: any = null; @@ -414,10 +398,10 @@ function _ensureEffectTypeIconSelect() { function _ensureEffectPaletteIconSelect() { const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null; if (!sel) return; - const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({ - value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`), + const gradients = _getGradients(); + const items = gradients.map(g => ({ + value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name, })); - items.push({ value: 'custom', icon: _icon(P.pencil), label: t('color_strip.palette.custom') }); if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; } _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 }); } @@ -451,8 +435,9 @@ function _ensureCandleTypeIconSelect() { function _ensureAudioPaletteIconSelect() { const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null; if (!sel) return; - const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({ - value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`), + const gradients = _getGradients(); + const items = gradients.map(g => ({ + value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name, })); if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; } _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 }); @@ -471,19 +456,19 @@ function _ensureAudioVizIconSelect() { } function _buildGradientPresetItems() { - const builtIn = [ - { value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') }, - ...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({ - value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`), - })), - ]; - const custom = loadCustomGradientPresets().map((p: any) => ({ - value: `__custom__${p.name}`, - icon: gradientPresetStripHTML(p.stops), - label: p.name, + const gradients = _getGradients(); + const builtInItems = gradients.filter(g => g.is_builtin).map(g => ({ + value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name, + })); + const userItems = gradients.filter(g => !g.is_builtin).map(g => ({ + value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name, isCustom: true, })); - return [...builtIn, ...custom]; + return [ + { value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') }, + ...builtInItems, + ...userItems, + ]; } function _ensureGradientPresetIconSelect() { @@ -491,7 +476,7 @@ function _ensureGradientPresetIconSelect() { if (!sel) return; const items = _buildGradientPresetItems(); if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } - _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 }); + _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, onChange: (v) => onGradientPresetChange(v) }); } /** Rebuild the preset picker after adding/removing custom presets. */ @@ -503,26 +488,26 @@ export function refreshGradientPresetPicker() { _renderCustomPresetList(); } -/** Render the custom preset list below the save button. */ +/** Render the user-created gradient list below the save button. */ function _renderCustomPresetList() { const container = document.getElementById('css-editor-custom-presets-list') as HTMLElement | null; if (!container) return; - const presets = loadCustomGradientPresets(); - if (presets.length === 0) { + const userGradients = _getGradients().filter(g => !g.is_builtin); + if (userGradients.length === 0) { container.innerHTML = ''; return; } - container.innerHTML = presets.map((p: any) => { - const strip = gradientPresetStripHTML(p.stops, 60, 14); - const safeName = escapeHtml(p.name); + container.innerHTML = userGradients.map(g => { + const strip = _gradientEntityStripHTML(g.stops, 60, 14); + const safeName = escapeHtml(g.name); return `
${strip} ${safeName}
`; }).join(''); @@ -551,38 +536,47 @@ function _buildAnimationTypeItems(cssType: any) { return items; } -/** Handles the gradient preset selector change — routes to built-in or custom preset. */ +/** Handles the gradient preset selector change — loads stops from gradient entity. */ export function onGradientPresetChange(value: any) { if (!value) return; // "— Custom —" selected - if (value.startsWith('__custom__')) { - applyCustomGradientPreset(value.slice('__custom__'.length)); - } else { - applyGradientPreset(value); + const g = _findGradient(value); + if (g) { + gradientInit(g.stops); } } -/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */ -export function promptAndSaveGradientPreset() { +/** Called from inline onclick in the HTML save button. Saves current gradient to server as new entity. */ +export async function promptAndSaveGradientPreset() { const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), ''); if (!name || !name.trim()) return; - saveCurrentAsCustomPreset(name.trim()); - showToast(t('color_strip.gradient.preset.saved'), 'success'); - refreshGradientPresetPicker(); + const stops = getGradientStops().map(s => ({ + position: s.position, + color: s.color, + })); + try { + await fetchWithAuth('/gradients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), stops }), + }); + await gradientsCache.fetch({ force: true }); + showToast(t('color_strip.gradient.preset.saved'), 'success'); + refreshGradientPresetPicker(); + } catch (e: any) { + showToast(e.message || 'Failed to save gradient', 'error'); + } } -/** Apply a custom preset by name. */ -export function applyCustomGradientPreset(name: any) { - const presets = loadCustomGradientPresets(); - const preset = presets.find((p: any) => p.name === name); - if (!preset) return; - gradientInit(preset.stops); -} - -/** Delete a custom preset and refresh the picker. */ -export function deleteAndRefreshGradientPreset(name: any) { - deleteCustomGradientPreset(name); - showToast(t('color_strip.gradient.preset.deleted'), 'success'); - refreshGradientPresetPicker(); +/** Delete a gradient entity and refresh the picker. */ +export async function deleteAndRefreshGradientPreset(gradientId: any) { + try { + await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); + await gradientsCache.fetch({ force: true }); + showToast(t('color_strip.gradient.preset.deleted'), 'success'); + refreshGradientPresetPicker(); + } catch (e: any) { + showToast(e.message || 'Failed to delete gradient', 'error'); + } } function _ensureAnimationTypeIconSelect(cssType: any) { @@ -596,17 +590,24 @@ function _ensureAnimationTypeIconSelect(cssType: any) { /* ── Effect type helpers ──────────────────────────────────────── */ -// Palette color control points — mirrors _PALETTE_DEFS in effect_stream.py -const _PALETTE_COLORS = { - fire: [[0,'0,0,0'],[0.33,'200,24,0'],[0.66,'255,160,0'],[1,'255,255,200']], - ocean: [[0,'0,0,32'],[0.33,'0,16,128'],[0.66,'0,128,255'],[1,'128,224,255']], - lava: [[0,'0,0,0'],[0.25,'128,0,0'],[0.5,'255,32,0'],[0.75,'255,160,0'],[1,'255,255,128']], - forest: [[0,'0,16,0'],[0.33,'0,80,0'],[0.66,'32,160,0'],[1,'128,255,64']], - rainbow: [[0,'255,0,0'],[0.17,'255,255,0'],[0.33,'0,255,0'],[0.5,'0,255,255'],[0.67,'0,0,255'],[0.83,'255,0,255'],[1,'255,0,0']], - aurora: [[0,'0,16,32'],[0.2,'0,80,64'],[0.4,'0,200,100'],[0.6,'64,128,255'],[0.8,'128,0,200'],[1,'0,16,32']], - sunset: [[0,'32,0,64'],[0.25,'128,0,128'],[0.5,'255,64,0'],[0.75,'255,192,64'],[1,'255,255,192']], - ice: [[0,'0,0,64'],[0.33,'0,64,192'],[0.66,'128,192,255'],[1,'240,248,255']], -}; +/** + * Build a gradient strip preview HTML from a GradientEntity's stops. + * Accepts [{position, color: [R,G,B]}, ...] format from the API. + */ +function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) { + const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); + return ``; +} + +/** Get cached gradient entities (or empty array if not yet loaded). */ +function _getGradients(): GradientEntity[] { + return gradientsCache.data || []; +} + +/** Find a gradient entity by ID. */ +function _findGradient(id: string): GradientEntity | undefined { + return _getGradients().find(g => g.id === id); +} // Default palette per effect type export function onEffectTypeChange() { @@ -639,9 +640,7 @@ export function onEffectTypeChange() { } export function onEffectPaletteChange() { - const palette = (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value; - const customGroup = document.getElementById('css-editor-effect-custom-palette-group') as HTMLElement; - if (customGroup) customGroup.style.display = palette === 'custom' ? '' : 'none'; + // No-op — kept for HTML onclick compatibility } /* ── Color Cycle helpers ──────────────────────────────────────── */ @@ -885,8 +884,9 @@ function _loadAudioState(css: any) { (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing; (document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2); - (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = css.palette || 'rainbow'; - if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(css.palette || 'rainbow'); + const audioGradientId = css.gradient_id || 'gr_builtin_rainbow'; + (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId; + if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId); (document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]); (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]); (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false; @@ -905,8 +905,8 @@ function _resetAudioState() { (document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0'; (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any; (document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30'; - (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'rainbow'; - if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('rainbow'); + (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow'; + if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow'); (document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00'; (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000'; (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false; @@ -939,7 +939,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: let propsHtml; if (isStatic) { - const hexColor = rgbArrayToHex(source.color); + const hexColor = rgbArrayToHex(source.color!); propsHtml = ` ${hexColor.toUpperCase()} @@ -1081,7 +1081,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: ${psNames.length ? `${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}` : ''} `; } else { - const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id]; + const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!]; const srcName = ps ? ps.name : source.picture_source_id || '—'; const cal = source.calibration ?? {} as Partial; const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); @@ -1209,18 +1209,26 @@ const _typeHandlers: Record any; reset: (... }, gradient: { load(css) { - (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = ''; - if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(''); - gradientInit(css.stops || [ + const presetId = css.gradient_id || ''; + (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = presetId; + if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(presetId); + // If gradient_id is set, load stops from the gradient entity + let stops = css.stops || [ { position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }, - ]); + ]; + if (presetId) { + const g = _findGradient(presetId); + if (g) stops = g.stops; + } + gradientInit(stops); _loadAnimationState(css.animation); (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear'; if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear'); }, reset() { (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = ''; + if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(''); gradientInit([ { position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }, @@ -1235,8 +1243,10 @@ const _typeHandlers: Record any; reset: (... cssEditorModal.showError(t('color_strip.gradient.min_stops')); return null; } + const gradientId = (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value || null; return { name, + gradient_id: gradientId, stops: gStops.map(s => ({ position: s.position, color: s.color, @@ -1252,36 +1262,33 @@ const _typeHandlers: Record any; reset: (... (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = css.effect_type || 'fire'; if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire'); onEffectTypeChange(); - (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = css.palette || 'fire'; - if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(css.palette || 'fire'); + const gradientId = css.gradient_id || 'gr_builtin_fire'; + (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId; + if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId); (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]); (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0; (document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1); (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0; (document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1); (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; - // Custom palette - const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement; - if (cpTextarea) cpTextarea.value = css.custom_palette ? JSON.stringify(css.custom_palette) : ''; onEffectPaletteChange(); }, reset() { (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire'; - (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'fire'; + (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire'; + if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire'); (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000'; (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any; (document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0'; (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any; (document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0'; (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; - const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement; - if (cpTextarea) cpTextarea.value = ''; }, getPayload(name) { const payload: any = { name, effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, - palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, + gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, @@ -1291,18 +1298,6 @@ const _typeHandlers: Record any; reset: (... const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; } - // Custom palette - if (payload.palette === 'custom') { - const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement).value.trim(); - if (cpText) { - try { - payload.custom_palette = JSON.parse(cpText); - } catch { - cssEditorModal.showError('Invalid custom palette JSON'); - return null; - } - } - } return payload; }, }, @@ -1321,7 +1316,7 @@ const _typeHandlers: Record any; reset: (... audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null, sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value), smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value), - palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, + gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value), color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, @@ -1622,6 +1617,9 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr (document.getElementById('css-editor-led-count') as HTMLInputElement).value = css.led_count ?? 0; }; + // Ensure gradient entities are loaded before opening the editor + await gradientsCache.fetch(); + // Initialize icon-grid type selector (idempotent) _ensureCSSTypeIconSelect(); @@ -1900,6 +1898,162 @@ export async function stopCSSOverlay(cssId: string) { } } +// ── Gradient entity management ──────────────────────────────────────── + +class GradientEditorModal extends Modal { + constructor() { super('gradient-editor-modal'); } + + snapshotValues() { + return { + name: (document.getElementById('gradient-editor-name') as HTMLInputElement).value, + description: (document.getElementById('gradient-editor-description') as HTMLInputElement).value, + stops: JSON.stringify(getGradientStops()), + tags: JSON.stringify(_gradientTagsInput ? _gradientTagsInput.getValue() : []), + }; + } + + onForceClose() { + if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; } + gradientSetIdPrefix(''); // Reset to default for CSS editor + } +} + +const gradientEditorModal = new GradientEditorModal(); +let _gradientTagsInput: any = null; + +/** Open the gradient editor modal for create, edit, or clone. */ +export async function showGradientModal(editId: string | null = null, cloneData: any = null) { + const titleEl = document.getElementById('gradient-editor-title')!; + const idInput = document.getElementById('gradient-editor-id') as HTMLInputElement; + const nameInput = document.getElementById('gradient-editor-name') as HTMLInputElement; + const descInput = document.getElementById('gradient-editor-description') as HTMLInputElement; + const errorEl = document.getElementById('gradient-editor-error') as HTMLElement; + + // Reset + idInput.value = ''; + nameInput.value = ''; + descInput.value = ''; + errorEl.style.display = 'none'; + + // Tags + if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; } + const tagsContainer = document.getElementById('gradient-editor-tags-container')!; + _gradientTagsInput = new TagInput(tagsContainer, { placeholder: t('tags.placeholder') }); + + let stops = [{ position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }]; + + if (editId) { + // Edit mode + const g = _findGradient(editId); + if (!g) return; + idInput.value = g.id; + nameInput.value = g.name; + descInput.value = g.description || ''; + _gradientTagsInput.setValue(g.tags || []); + stops = g.stops; + titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.edit')}`; + } else if (cloneData) { + // Clone mode + nameInput.value = (cloneData.name || '') + ' (Copy)'; + descInput.value = cloneData.description || ''; + _gradientTagsInput.setValue(cloneData.tags || []); + stops = cloneData.stops || stops; + titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`; + } else { + // Create mode + titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`; + } + + gradientEditorModal.open(); + // Init gradient editor with ge- prefix (standalone modal, not CSS editor) + requestAnimationFrame(() => { + gradientSetIdPrefix('ge-'); + gradientInit(stops); + gradientEditorModal.snapshot(); + }); +} + +/** Close the gradient editor modal (with dirty check). */ +export async function closeGradientEditor() { + await gradientEditorModal.close(); +} + +/** Save gradient entity from the editor modal. */ +export async function saveGradientEntity() { + const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value; + const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim(); + const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null; + const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : []; + const errorEl = document.getElementById('gradient-editor-error') as HTMLElement; + + if (!name) { + errorEl.textContent = t('gradient.error.name_required'); + errorEl.style.display = ''; + return; + } + + const stops = getGradientStops().map(s => ({ + position: s.position, + color: [...s.color], + })); + + if (stops.length < 2) { + errorEl.textContent = t('gradient.error.min_stops'); + errorEl.style.display = ''; + return; + } + + const payload: any = { name, stops, description, tags }; + + try { + const url = id ? `/gradients/${id}` : '/gradients'; + const method = id ? 'PUT' : 'POST'; + const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); + if (!res!.ok) { + const err = await res!.json(); + throw new Error(err.detail || 'Failed to save gradient'); + } + + showToast(id ? t('gradient.updated') : t('gradient.created'), 'success'); + gradientsCache.invalidate(); + gradientEditorModal.forceClose(); + if (window.loadPictureSources) await window.loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + errorEl.textContent = e.message; + errorEl.style.display = ''; + } +} + +/** Clone a gradient entity — opens editor with cloned data. */ +export async function cloneGradient(gradientId: string) { + const g = _findGradient(gradientId); + if (!g) return; + await showGradientModal(null, g); +} + +/** Edit a gradient entity. */ +export async function editGradient(gradientId: string) { + await showGradientModal(gradientId); +} + +/** Delete a gradient entity (with confirmation). */ +export async function deleteGradient(gradientId: string) { + const g = _findGradient(gradientId); + if (!g) return; + const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name })); + if (!ok) return; + try { + await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); + gradientsCache.invalidate(); + showToast(t('gradient.deleted'), 'success'); + if (window.loadPictureSources) await window.loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t('gradient.error.delete_failed'), 'error'); + } +} + // ── Test / Preview modal (extracted to color-strips-test.ts) ── export { previewCSSFromEditor, diff --git a/server/src/wled_controller/static/js/features/css-gradient-editor.ts b/server/src/wled_controller/static/js/features/css-gradient-editor.ts index ab4adbd..a135c7c 100644 --- a/server/src/wled_controller/static/js/features/css-gradient-editor.ts +++ b/server/src/wled_controller/static/js/features/css-gradient-editor.ts @@ -54,6 +54,12 @@ let _gradientStops: GradientStop[] = []; let _gradientSelectedIdx: number = -1; let _gradientDragging: GradientDragState | null = null; let _gradientOnChange: (() => void) | null = null; +let _idPrefix: string = ''; + +/** Set an ID prefix for DOM elements (e.g. 'ge-' to find 'ge-gradient-canvas'). */ +export function gradientSetIdPrefix(prefix: string): void { _idPrefix = prefix; } + +function _el(id: string): HTMLElement | null { return document.getElementById(_idPrefix + id); } /** Set a callback that fires whenever stops change. */ export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; } @@ -215,7 +221,7 @@ export function gradientRenderAll(): void { } function _gradientRenderCanvas(): void { - const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null; + const canvas = _el('gradient-canvas') as HTMLCanvasElement | null; if (!canvas) return; // Sync canvas pixel width to its CSS display width @@ -241,7 +247,7 @@ function _gradientRenderCanvas(): void { } function _gradientRenderMarkers(): void { - const track = document.getElementById('gradient-markers-track'); + const track = _el('gradient-markers-track'); if (!track) return; track.innerHTML = ''; @@ -276,7 +282,7 @@ function _gradientSelectStop(idx: number): void { } function _gradientRenderStopList(): void { - const list = document.getElementById('gradient-stops-list'); + const list = _el('gradient-stops-list'); if (!list) return; list.innerHTML = ''; @@ -305,7 +311,7 @@ function _gradientRenderStopList(): void { row.addEventListener('mousedown', () => _gradientSelectStop(idx)); // Position - const posInput = row.querySelector('.gradient-stop-pos'); + const posInput = row.querySelector('.gradient-stop-pos')!; posInput.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0)); @@ -316,7 +322,7 @@ function _gradientRenderStopList(): void { posInput.addEventListener('focus', () => _gradientSelectStop(idx)); // Left color - row.querySelector('.gradient-stop-color').addEventListener('input', (e) => { + row.querySelector('.gradient-stop-color')!.addEventListener('input', (e) => { const val = (e.target as HTMLInputElement).value; _gradientStops[idx].color = hexToRgbArray(val); const markers = document.querySelectorAll('.gradient-marker'); @@ -325,7 +331,7 @@ function _gradientRenderStopList(): void { }); // Bidirectional toggle - row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => { + row.querySelector('.gradient-stop-bidir-btn')!.addEventListener('click', (e) => { e.stopPropagation(); _gradientStops[idx].colorRight = _gradientStops[idx].colorRight ? null @@ -335,13 +341,13 @@ function _gradientRenderStopList(): void { }); // Right color - row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => { + row.querySelector('.gradient-stop-color-right')!.addEventListener('input', (e) => { _gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value); _gradientRenderCanvas(); }); // Remove - row.querySelector('.btn-danger').addEventListener('click', (e) => { + row.querySelector('.btn-danger')!.addEventListener('click', (e) => { e.stopPropagation(); if (_gradientStops.length > 2) { _gradientStops.splice(idx, 1); @@ -382,7 +388,7 @@ export function gradientAddStop(position?: number): void { /* ── Drag ─────────────────────────────────────────────────────── */ function _gradientStartDrag(e: MouseEvent, idx: number): void { - const track = document.getElementById('gradient-markers-track'); + const track = _el('gradient-markers-track'); if (!track) return; _gradientDragging = { idx, trackRect: track.getBoundingClientRect() }; @@ -442,7 +448,7 @@ export function deleteCustomGradientPreset(name: string): void { /* ── Track click → add stop ───────────────────────────────────── */ function _gradientSetupTrackClick(): void { - const track = document.getElementById('gradient-markers-track'); + const track = _el('gradient-markers-track'); if (!track || (track as any)._gradientClickBound) return; (track as any)._gradientClickBound = true; diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 364935d..1cfaa60 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -37,6 +37,7 @@ import { audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache, colorStripSourcesCache, csptCache, stripFiltersCache, + gradientsCache, GradientEntity, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; @@ -55,7 +56,7 @@ import { ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, - ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, + ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; @@ -109,6 +110,8 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); +const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }]; +const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction }); // Re-render picture sources when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); @@ -220,13 +223,14 @@ export async function loadPictureSources() { audioTemplatesCache.fetch(), colorStripSourcesCache.fetch(), csptCache.fetch(), + gradientsCache.fetch(), filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data), ]); renderPictureSourcesList(streams); } catch (error) { if (error.isAuth) return; console.error('Error loading picture sources:', error); - document.getElementById('streams-list').innerHTML = ` + document.getElementById('streams-list')!.innerHTML = `
${t('streams.error.load')}: ${error.message}
`; } finally { @@ -273,7 +277,7 @@ const _streamSectionMap = { }; function renderPictureSourcesList(streams: any) { - const container = document.getElementById('streams-list'); + const container = document.getElementById('streams-list')!; const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; const renderStreamCard = (stream: any) => { @@ -329,7 +333,7 @@ function renderPictureSourcesList(streams: any) { removeTitle: t('common.delete'), content: `
-
${typeIcon} ${escapeHtml(stream.name)}
+
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} ${renderTagChips(stream.tags)} @@ -352,7 +356,7 @@ function renderPictureSourcesList(streams: any) { removeTitle: t('common.delete'), content: `
-
${ICON_TEMPLATE} ${escapeHtml(template.name)}
+
${ICON_TEMPLATE} ${escapeHtml(template.name)}
${template.description ? `
${escapeHtml(template.description)}
` : ''}
@@ -398,7 +402,7 @@ function renderPictureSourcesList(streams: any) { removeTitle: t('common.delete'), content: `
-
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
+
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} ${filterChainHtml} @@ -424,7 +428,7 @@ function renderPictureSourcesList(streams: any) { removeTitle: t('common.delete'), content: `
-
${ICON_CSPT} ${escapeHtml(tmpl.name)}
+
${ICON_CSPT} ${escapeHtml(tmpl.name)}
${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: `
-
${icon} ${escapeHtml(src.name)}
+
${icon} ${escapeHtml(src.name)}
${propsHtml}
${renderTagChips(src.tags)} ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, actions: ` - - - `, + + + `, }); }; @@ -575,7 +583,7 @@ function renderPictureSourcesList(streams: any) { removeTitle: t('common.delete'), content: `
-
${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
+
${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
${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 = ``; + const editBtn = g.is_builtin ? '' : ``; + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: g.id, + removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}
+
+ ${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 @@
- -