From c716c7c09fe8c7f429a2d11b1763b3a4a2cc1ca9 Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 20:59:17 +0000 Subject: [PATCH 1/6] Riverlea - add web components for viewing/previewing/editing streams --- .../Api4/Action/RiverleaStream/Activate.php | 90 ++++ ext/riverlea/Civi/Riverlea/StyleLoader.php | 29 +- ext/riverlea/css/stream-list.css | 60 +++ ext/riverlea/js/editor.js | 322 +++++++++++++ ext/riverlea/js/previewer.js | 319 +++++++++++++ ext/riverlea/js/stream-list.js | 426 ++++++++++++++++++ ext/riverlea/js/utils.js | 18 + 7 files changed, 1260 insertions(+), 4 deletions(-) create mode 100644 ext/riverlea/Civi/Api4/Action/RiverleaStream/Activate.php create mode 100644 ext/riverlea/css/stream-list.css create mode 100644 ext/riverlea/js/editor.js create mode 100644 ext/riverlea/js/previewer.js create mode 100644 ext/riverlea/js/stream-list.js create mode 100644 ext/riverlea/js/utils.js diff --git a/ext/riverlea/Civi/Api4/Action/RiverleaStream/Activate.php b/ext/riverlea/Civi/Api4/Action/RiverleaStream/Activate.php new file mode 100644 index 000000000000..9005d6f9e7b4 --- /dev/null +++ b/ext/riverlea/Civi/Api4/Action/RiverleaStream/Activate.php @@ -0,0 +1,90 @@ +first based + * on your chosen order by... + */ +class Activate extends \Civi\Api4\Generic\BasicBatchAction { + + /** + * @var string + * + * Activate this stream as 'backend' theme, 'frontend' theme + * or 'both' + */ + protected string $backOrFront = 'backend'; + + /** + * @inheritdoc + */ + protected function getSelect(): array { + return [ + 'name', 'label', + 'extension', 'file_prefix', + 'css_file', 'css_file_dark', + 'vars', 'vars_dark', + 'custom_css', 'custom_css_dark', + 'dark_frontend', 'dark_backend', + ]; + } + + /** + * @inheritdoc + * + * Set this stream as the theme for frontend or backend + */ + protected function doTask($stream): array { + $streamName = $stream['name']; + + // validate the theme is available for this stream + $available = \Civi::service('themes')->getAvailable(); + if (empty($available[$streamName])) { + throw new \CRM_Core_Exception("This stream is not available to select as a Civi theme. Maybe it has been disabled?"); + } + + $themeSettings = []; + + switch ($this->backOrFront) { + case 'backend': + $themeSettings[] = 'theme_backend'; + break; + + case 'frontend': + $themeSettings[] = 'theme_frontend'; + break; + + case 'both': + $themeSettings[] = 'theme_backend'; + $themeSettings[] = 'theme_frontend'; + break; + + default: + throw new \CRM_Core_Exception("Invalid value '{$this->backOrFront}' for back or front - should be 'backend', 'frontend', or 'both'"); + } + + foreach ($themeSettings as $setting) { + \Civi::settings()->set($setting, $streamName); + } + + return []; + } + +} diff --git a/ext/riverlea/Civi/Riverlea/StyleLoader.php b/ext/riverlea/Civi/Riverlea/StyleLoader.php index d2674a4f2738..4772062cf390 100644 --- a/ext/riverlea/Civi/Riverlea/StyleLoader.php +++ b/ext/riverlea/Civi/Riverlea/StyleLoader.php @@ -110,6 +110,12 @@ public function alterBundles(GenericHookEvent $e): void { */ $bundle = $e->bundle; + if ($bundle->name === 'coreResources') { + if (\CRM_Core_Permission::check('administer CiviCRM')) { + $bundle->addScriptFile('riverlea', 'js/previewer.js'); + } + } + if ($bundle->name === 'bootstrap3') { $bundle->clear(); $bundle->addStyleFile('riverlea', 'core/css/_bootstrap.css'); @@ -159,23 +165,38 @@ protected function getAvailableStreamMeta(): array { } public function getCssParams(): array { - $stream = \Civi::service('themes')->getActiveThemeKey(); + $stream = $this->getStream(); // we add the stream modified date to asset params as a cache buster - $streamMeta = $this->getAvailableStreamMeta()[$stream] ?? []; - $streamModified = $streamMeta['modified_date'] ?? NULL; + $streamModified = $stream['modified_date'] ?? NULL; $isFrontend = \CRM_Utils_System::isFrontendPage(); $darkMode = $isFrontend ? \Civi::settings()->get('riverlea_dark_mode_frontend') : \Civi::settings()->get('riverlea_dark_mode_backend'); return [ - 'stream' => $stream, + 'stream' => $stream['name'], 'modified' => $streamModified, 'is_frontend' => $isFrontend, 'dark_mode' => $darkMode, ]; } + protected function getStream(): array { + $streamMeta = self::getAvailableStreamMeta(); + + // admins can preview other streams using a url param + if (\CRM_Core_Permission::check('administer CiviCRM')) { + $streamOverride = \CRM_Utils_Request::retrieve('stream_override', 'String'); + // check override is a valid key before using + if (isset($streamMeta[$streamOverride])) { + return $streamMeta[$streamOverride]; + } + } + + $key = \Civi::service('themes')->getActiveThemeKey(); + return $streamMeta[$key]; + } + /** * Generate asset content (when accessed via AssetBuilder). * diff --git a/ext/riverlea/css/stream-list.css b/ext/riverlea/css/stream-list.css new file mode 100644 index 000000000000..b4d5ebb11213 --- /dev/null +++ b/ext/riverlea/css/stream-list.css @@ -0,0 +1,60 @@ +/* CSS rules for Riverlea stream admin */ + +civi-riverlea-stream-list ul { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr) ); + padding-left: 0; + gap: 1rem; + margin: 0; +} + +civi-riverlea-stream-card { + display: block; +} +civi-riverlea-stream-card .panel-heading { + display: flex; + justify-content: space-between; + align-items: center; +} +civi-riverlea-stream-card .btn.btn-stream-selected { + background-color: var(--crm-c-success); + color: var(--crm-c-success-text); +} +civi-riverlea-stream-card .btn.btn-set-preview.btn-stream-selected { + background-color: var(--crm-c-warning); + color: var(--crm-c-warning-text); +} + +civi-riverlea-stream-card .civi-riverlea-stream-details code { + display: inline-block; +} + +civi-riverlea-stream-list .civi-riverlea-stream-edit-dialog { + width: 100%; +} + +civi-riverlea-stream-editor iframe { + width: 100%; + height: 100%; +} +civi-riverlea-stream-editor, +civi-riverlea-stream-editor fieldset { + display: flex; + flex-direction: column; +} + +civi-riverlea-stream-variable { + display: flex; + flex-flow: row; + justify-content: space-between; +} + +civi-riverlea-stream-variable input[type="color"] { + width: 3rem; +} +civi-riverlea-stream-variable .input-group { + display: flex; + flex-flow: row nowrap; + gap: 0.5rem; + align-items: center; +} diff --git a/ext/riverlea/js/editor.js b/ext/riverlea/js/editor.js new file mode 100644 index 000000000000..64a641fac0ce --- /dev/null +++ b/ext/riverlea/js/editor.js @@ -0,0 +1,322 @@ +(function (CRM) { + + /** + * + * + */ + class CiviRiverleaStreamEditor extends HTMLElement { + constructor() { + super(); + + this.data = {}; + } + + connectedCallback() { + // initialise basic internal structure + this.innerHTML = ` +
+
+
+ +
+
+
+
+
+ `; + + // add preview iframe + this.previewFrame = document.createElement('iframe'); + this.previewFrame.src = CRM.url(''); + this.querySelector('.civi-riverlea-stream-editor-preview-container').append(this.previewFrame); + + this.previewFrame.addEventListener('load', () => this.refreshPreview()); + + // render the buttons + this.renderButtons(); + this.renderInputs(); + } + + disconnectedCallback() { + delete this.previewFrame; + } + + fetchStreamData() { + // initialise store for unsaved values + return CRM.api4('RiverleaStream', 'getWithFileContent', { + where: [['name', '=', this.streamName]] + }) + .then((result) => { + if (result && result.length) { + return result[0]; + } + throw new Error('Unable to load editor for ' + streamName); + }) + .then((stream) => this.setStreamData(stream)); + } + + setStreamData(streamData) { + this.data = streamData; + // initialise copy for unsaved edits + this.unsavedData = {...this.data}; + } + + save() { + return CRM.api4('RiverleaStream', 'update', { + where: [['name', '=', this.streamName]], + values: this.unsavedData + }) + .then(() => CRM.alert(ts('Stream edits saved'), '', 'success')) + .then(() => this.dispatchEvent(new Event('save'))); + } + + reset() { + this.unsavedData = {...this.data}; + this.renderInputs(); + this.refreshPreview(); + this.dispatchEvent(new Event('reset')); + } + + refreshPreview() { + const previewDocument = this.previewFrame.contentDocument; + if (!previewDocument) { + // this can happen, it's usually not a problem + console.debug('Editor preview frame is not loaded'); + return; + } + + // remove any session preview selector + previewDocument.querySelectorAll('civi-riverlea-preview-selector').forEach((el) => el.remove()); + + let framePreview = previewDocument.querySelector('civi-riverlea-stream-preview'); + + if (!framePreview) { + framePreview = previewDocument.createElement('civi-riverlea-stream-preview'); + previewDocument.querySelector('body').append(framePreview); + } + + // this ensures any css_file content for the stream is loaded in the frame + framePreview.render(this.unsavedData); + } + + renderButtons() { + const createButton = CRM.riverlea.createButton; + + const saveButton = createButton(ts('Save'), 'btn-primary', 'save', () => this.save()); + saveButton.type = 'submit'; + const cancelButton = createButton(ts('Cancel'), 'btn-secondary', 'xmark', () => this.reset()); + cancelButton.type = 'submit'; + + this.querySelector('.civi-riverlea-stream-editor-buttons') + .append(saveButton, cancelButton); + } + + renderInputs() { + const inputContainer = this.querySelector('.civi-riverlea-stream-editor-input-container'); + + inputContainer.innerHTML = ` +

${this.data.label}

+ +
+ +
+

${this.darkMode ? ts('Dark-mode colors') : ts('Colors')}

+ +
+ + +
+
+ `; + + const createVariableInput = (label, streamField, name, type, unit = null) => { + const el = document.createElement('civi-riverlea-stream-variable'); + el.setAttribute('stream-field', streamField); + el.setAttribute('name', name); + el.setAttribute('type', type); + el.setAttribute('label', label); + if (unit) { + el.setAttribute('unit', unit); + } + + return el; + }; + + const createTextInput = (labelText, streamField, textArea = false) => { + const group = document.createElement('div'); + + const input = textArea ? document.createElement('textarea') : document.createElement('input'); + if (!textArea) { + input.type = 'text'; + } + + input.value = this.unsavedData[streamField] ?? ''; + input.onchange = () => { + this.unsavedData[streamField] = input.value; + this.refreshPreview(); + }; + + const label = document.createElement('label'); + label.innerText = labelText; + + group.append(label, input); + + return group; + }; + + inputContainer.querySelector('.civi-riverlea-stream-meta-inputs').append( + createTextInput(ts('Stream Name'), 'label'), + createTextInput(ts('Description'), 'description', true) + ); + + const colorSchemeVarsTarget = 'vars'; // TODO: 'var_dark' + + inputContainer.querySelector('.civi-riverlea-stream-color-inputs').append( + createVariableInput(ts('Text'), colorSchemeVarsTarget, '--crm-text-color', 'color'), + createVariableInput(ts('Background'), colorSchemeVarsTarget, '--crm-container-bg-color', 'color'), + createVariableInput(ts('Page Background'), colorSchemeVarsTarget, '--crm-page-bg-color', 'color'), + createVariableInput(ts('Primary Highlight'), colorSchemeVarsTarget, '--crm-primary-color', 'color'), + createVariableInput(ts('Primary Text'), colorSchemeVarsTarget, '--crm-primary-text-color', 'color'), + createVariableInput(ts('Secondary Highlight'), colorSchemeVarsTarget, '--crm-secondary-text', 'color'), + createVariableInput(ts('Secondary Text'), colorSchemeVarsTarget, '--crm-secondary-text-color', 'color'), + ); + + inputContainer.querySelector('.civi-riverlea-stream-size-inputs').append( + createVariableInput(ts('Font Size'), 'vars', '--crm-font-size', 'number', 'rem'), + createVariableInput(ts('Roundness'), 'vars', '--crm-l-radius', 'number', 'rem') + ); + + inputContainer.querySelector('.civi-riverlea-stream-custom-inputs').append( + createTextInput(ts('Custom CSS'), 'custom_css', true), + createTextInput(ts('Darkmode Custom CSS'), 'custom_css_dark', true) + ); + } + } + + customElements.define('civi-riverlea-stream-editor', CiviRiverleaStreamEditor); + + /** + * + * + */ + class CiviRiverleaStreamVariable extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + + this.editor = this.closest('civi-riverlea-stream-editor'); + if (!this.editor) { + console.warn('civi-riverlea-stream-variable must be placed within civi-riverlea-stream-editor'); + return; + } + + this.render(); + } + + get streamField() { + return this.getAttribute('stream-field'); + } + + get name() { + return this.getAttribute('name'); + } + + get type() { + return this.getAttribute('type'); + } + + get unit() { + return this.getAttribute('unit'); + } + + get label() { + return this.getAttribute('label'); + } + + get value() { + if (!this.editor.unsavedData[this.streamField]) { + return null; + } + const val = this.editor.unsavedData[this.streamField][this.name] ?? null; + if (val && this.unit) { + return val.replace(this.unit, ''); + } + return val; + } + + set value(val) { + // ensure the target field is an assignable object + this.editor.unsavedData[this.streamField] = Object.assign({}, this.editor.unsavedData[this.streamField]); + + if (this.unit) { + val = `${val}${this.unit}`; + console.log(val); + } + + // do the assignment + this.editor.unsavedData[this.streamField][this.name] = val; + + // special handling for null values + if (!val && val !== 0) { + delete this.editor.unsavedData[this.streamField][this.name]; + } + this.editor.refreshPreview(); +// else { +// // update preview sheet +// this.previewStyles.insertRule(`:root { ${this.name}: ${val} }`, this.previewStyles.cssRules.length); +// } + + } + + get previewStyles() { + return this.editor.previewStyles; + } + + render() { + const createButton = CRM.riverlea.createButton; + + this.innerHTML = ''; + + const label = document.createElement('label'); + label.innerText = this.label; + this.append(label); + + if (this.type === 'color' && !this.value) { + const addColor = createButton(ts('Add'), 'btn-add', 'palette', () => { + // set to a non blank color and then rerender + this.value = '#ffffff'; + this.render(); + }); + this.append(addColor); + return; + } + + const input = document.createElement('input'); + input.type = this.type; + input.name = `${this.editor.streamName}_${this.streamField}_${this.name}`; + input.value = this.value; + input.onchange = () => { + this.value = input.value; + }; + + input.step = '0.1'; + + const clear = createButton(ts('Clear'), 'btn-clear', 'xmark', () => { + this.value = null; + this.render(); + }); + + const inputAndClear = document.createElement('div'); + inputAndClear.classList.add('input-group'); + inputAndClear.append(input, clear); + this.append(inputAndClear); + } + } + + customElements.define('civi-riverlea-stream-variable', CiviRiverleaStreamVariable); + +})(CRM); + + diff --git a/ext/riverlea/js/previewer.js b/ext/riverlea/js/previewer.js new file mode 100644 index 000000000000..f358ff7f323c --- /dev/null +++ b/ext/riverlea/js/previewer.js @@ -0,0 +1,319 @@ +(function (CRM) { + /** + * + * + */ + class CiviRiverleaStreamPreview extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + // add our constructed stylesheet + this.previewStyles = new CSSStyleSheet(); + document.adoptedStyleSheets.push(this.previewStyles); + this.fetchCoreDarkRules(); + } + + disconnectedCallback() { + delete this.previewStyles; + } + + isDefaultRiver(href) { + if (href.includes('river.css')) { + return true; + } + if (href.includes('dyn/river')) { + return true; + } + return false; + } + + removeDefaultRiver() { + const river = Array.from(document.styleSheets).find((sheet) => + sheet.ownerNode && sheet.ownerNode.href && this.isDefaultRiver(sheet.ownerNode.href) + ); + if (river) { + river.ownerNode.remove(); + } + } + + async fetchCoreDarkRules() { + if (this.coreDarkRules) return; + return fetch(CRM.resourceUrls.civicrm + '/ext/riverlea/core/css/_dark.css') + .then((response) => response.text()) + .then((content) => this.coreDarkRules = content); + } + + loadStyles(styles) { + this.removeDefaultRiver(); + + // add a whole-page loading filter + this.previewStyles.replaceSync(` + body { + filter: blur(4px)!important; + } + `); + + return this.previewStyles.replace(styles); + } + + loadStreamById(id, darkMode = 'inherit') { + return CRM.api4('RiverleaStream', 'render', { + where: [['id', '=', id]], + darkMode: darkMode, + }) + .then((result) => result[0].content) + .then((content) => this.loadStyles(content)); + } + + async render(streamData) { + await this.fetchCoreDarkRules(); + const styles = this.renderCss(streamData); + + this.loadStyles(styles); + } + + renderCss(streamData) { + if (!streamData) { + return ''; + } + + const fileRules = streamData.css_file_content ?? ''; + const fileRulesDark = streamData.css_file_dark_content ?? ''; + + const varRules = Object.entries(streamData.vars ?? {}).map((entry) => `${entry[0]}: ${entry[1]};`); + const varRulesDark = Object.entries(streamData.vars_dark ?? {}).map((entry) => `${entry[0]}: ${entry[1]};`); + + const customCss = streamData.custom_css ?? ''; + const customCssDark = streamData.custom_css_dark ?? ''; + + const lightRules = ` + ${fileRules} + + :root { + ${varRules.join("\n")} + } + + ${customCss} + `; + + const darkRules = ` + ${this.coreDarkRules} + + ${fileRulesDark} + + :root { + ${varRulesDark.join("\n")} + } + + ${customCssDark} + `; + + switch (this.darkModeSetting) { + case 'light': + return lightRules; + + case 'dark': + return ` + ${lightRules} + + ${darkRules} + `; + + case 'inherit': + default: + return ` + ${lightRules} + + @media (prefers-color-scheme: dark) { + ${darkRules} + } + `; + } + } + + } + + // register custom element in our civi namespace + customElements.define('civi-riverlea-stream-preview', CiviRiverleaStreamPreview); + /** + * + * + */ + class CiviRiverleaPreviewSelector extends HTMLElement { + constructor() { + super(); + + this.streams = {}; + } + + connectedCallback() { + this.style.backgroundColor = 'white'; + this.style.padding = '0.5rem'; + this.renderer = this.getOrCreateRenderer(); + this.append(this.renderer); + + const label = document.createElement('label'); + label.innerText = ts('Theme Preview'); + this.append(label); + + // create selector element + this.selector = document.createElement('select'); + this.selector.addEventListener('change', () => { + const selectedOption = this.selector.selectedOptions[0]; + + // update stored selection + this.setSelected(selectedOption.value); + }); + this.selector.style.marginLeft = '1rem'; + + label.append(this.selector); + + this.load(); + } + + getOrCreateRenderer() { + const existing = document.querySelector('civi-riverlea-stream-preview'); + return existing ? existing : document.createElement('civi-riverlea-stream-preview'); + } + + fetchAllStreams() { + return CRM.api4('RiverleaStream', 'getWithFileContent', { + where: [['id', '>', 0]] + }) + .then((streams) => streams.forEach((stream) => this.streams[stream.name] = stream)); + } + + renderSelector() { + Object.assign(this.style, { + position: 'fixed', + bottom: '1rem', + right: '1rem', + zIndex: 1000, + }); + + this.selector.innerHTML = ''; + + // add blank option + this.blankOption = document.createElement('option'); + this.blankOption.value = ''; + this.blankOption.innerText = this.selected ? ts('- end preview -') : ts('- select -'); + this.selector.append(this.blankOption); + + Object.values(this.streams).forEach((stream) => { + const option = document.createElement('option'); + option.value = stream.name; + option.innerText = stream.label; + option.selected = (option.value === this.selected); + this.selector.append(option); + }); + } + + load() { + const sessionData = CRM.riverlea.previewSession(); + + if (sessionData && sessionData.selected) { + this.streams = sessionData.streams; + this.renderSelector(); + this.setSelected(sessionData.selected, false); + return Promise.resolve(); + } + else { + return this.fetchAllStreams() + .then(() => this.renderSelector()); + } + } + + saveSession() { + CRM.riverlea.previewSession({ + streams: this.streams, + selected: this.selected, + }); + } + + endSession() { + CRM.riverlea.previewSession(false); + window.location.reload(); + } + + setSelected(value, notify = true) { + if (!value || value === '') { + this.endSession(); + return; + } + + if (!this.streams[value]) { + console.warn('Stream not available'); + return; + } + + // notify if the the stream is changing + if (notify && (this.selected !== value)) { + CRM.alert(ts('Previewing stream: ') + this.streams[value].label, '', 'info'); + } + + this.selected = value; + + Array.from(this.selector.options).forEach((option) => option.selected = (option.value === this.selected)); + this.blankOption.innerText = ts('- end preview -'); + + this.saveSession(); + + this.renderPreview(); + } + + renderPreview() { + const streamData = this.streams[this.selected]; + + this.renderer.render(streamData); + } + } + + // register custom element in our civi namespace + customElements.define('civi-riverlea-preview-selector', CiviRiverleaPreviewSelector); +})(CRM); + + +/** + * +* CRM.riverlea has global functions for the preview session state + */ +(function (CRM) { + CRM.riverlea = CRM.riverlea || {}; + + CRM.riverlea.previewer = (init = true) => { + const existing = document.querySelector('civi-riverlea-preview-selector'); + if (existing) { + return existing; + } + if (!init) { + return null; + } + const previewer = document.createElement('civi-riverlea-preview-selector'); + document.querySelector('body').append(previewer); + return previewer; + }; + + CRM.riverlea.previewSession = (value) => { + if (value === undefined) { + return JSON.parse(sessionStorage.getItem('civi_riverlea_preview_session')); + } + if (!value) { + sessionStorage.removeItem('civi_riverlea_preview_session'); + } + sessionStorage.setItem('civi_riverlea_preview_session', JSON.stringify(value)); + }; + + CRM.riverlea.checkForPreviewSession = () => { + // if we find a preview setting in the session + // then reopen the previewer immediately + const previewSession = CRM.riverlea.previewSession(); + if (previewSession && previewSession.selected) { + CRM.riverlea.previewer(); + } + }; + + document.addEventListener('DOMContentLoaded', () => CRM.riverlea.checkForPreviewSession()); +})(CRM); + diff --git a/ext/riverlea/js/stream-list.js b/ext/riverlea/js/stream-list.js new file mode 100644 index 000000000000..3ed4a0dc43d9 --- /dev/null +++ b/ext/riverlea/js/stream-list.js @@ -0,0 +1,426 @@ +(function (CRM) { + + /** + * + * + */ + class CiviRiverleaStreamList extends HTMLElement { + constructor() { + super(); + + this.streamData = {}; + } + + connectedCallback() { + this.innerHTML = ` +
+
+

+
+
+
+

+
+
+
+
+

+
    +
    + `; + this.querySelector('.civi-backend h3').innerText = ts('Current Backend Theme'); + this.backendSlot = this.querySelector('.civi-backend div'); + + this.querySelector('.civi-frontend h3').innerText = ts('Current Frontend Theme'); + this.frontendSlot = this.querySelector('.civi-frontend div'); + + // create list for other streams + this.querySelector('.civi-themes-available h3').innerText = ts('Other Available Themes'); + this.ul = this.querySelector('.civi-themes-available ul'); + + // button to create a new stream FIXME only allow cloning for now + // const addButton = CRM.riverlea.createButton(ts('Add new stream'), 'btn-primary', 'plus', () => this.createNew().then(() => this.fetchAndRender())); + // this.querySelector('.civi-other-themes').append(addButton); + + // create editor dialog + this.editorDialog = document.createElement('dialog'); + this.editorDialog.classList.add('crm-dialog', 'civi-riverlea-stream-edit-dialog'); + this.append(this.editorDialog); + + this.fetchAndRender(); + } + + async fetchAndRender() { + this.ul.innerHTML = '
    '; + + return Promise.all([this.fetchRecords(), this.fetchSettingState()]) + .then(() => this.render()); + } + + + async fetchRecords() { + this.streams = {}; + + return CRM.api4('RiverleaStream', 'getWithFileContent', { + where: [['id', '!=', 0]] + }) + .then((streams) => streams.forEach((stream) => this.streams[stream.name] = stream)); + } + + fetchSettingState() { + this.settingState = {}; + + const previewSession = CRM.riverlea.previewSession(); + this.settingState.preview = previewSession ? previewSession.selected : null; + + return CRM.api4('Setting', 'get', { select: ['theme_backend', 'theme_frontend'] }) + .then((results) => results.forEach((record) => { + const settingNameWithoutPrefix = record.name.split('_')[1]; + this.settingState[settingNameWithoutPrefix] = record.value; + })); + } + + createNew() { + return CRM.api4('RiverleaStream', 'create', { + values: { + label: 'New stream' , + name: 'custom_' + window.crypto.getRandomValues(new Uint32Array(1)).join('') + } + }); + } + + clone(streamName) { + const sourceData = this.streams[streamName] ?? null; + if (!sourceData) { + console.warn('Missing stream to clone'); + return; + } + const cloneData = {...sourceData}; + + // remove id + delete cloneData.id; + // allow editing clones of reserved themes + cloneData.is_reserved = false; + + // get a unique name + let i = 0; + let nameClash = true; + while (nameClash) { + i += 1; + cloneData.name = `${sourceData.name}_${i}`; + nameClash = Object.keys(this.streams).includes(cloneData.name); + } + + // set a label based on the name index + cloneData.label += ts(' (Copy %1)', {1: i}); + + // set a description based on the copy + cloneData.description = ts('Copied from ') + sourceData.label; + + return CRM.api4('RiverleaStream', 'create', { + values: cloneData + }) + .then(() => this.fetchAndRender()) + .then(() => this.openEditorDialog(cloneData.name)); + } + + delete(streamName) { + // check if the stream to delete is in use + for (const [key, value] of Object.entries(this.settingState)) { + if (value !== streamName) { + continue; + } + if (key === 'preview') { + this.updateSetting('preview', null); + continue; + } + // this stream is set for + CRM.alert(ts('Cannot delete stream as currently set for ') + key); + return; + } + + // always preview the stream we are editing + return CRM.api4('RiverleaStream', 'delete', { + where: [['name', '=', streamName]] + }) + .then(() => CRM.alert(ts('Stream deleted'))) + .then(() => delete this.streams[streamName]) + .then(() => this.render()); + } + + render() { + this.backendSlot.innerHTML = ''; + this.frontendSlot.innerHTML = ''; + this.ul.innerHTML = ''; + + Object.values(this.streams).forEach((stream) => { + const card = document.createElement('civi-riverlea-stream-card'); + card.setData(stream); + card.setState('is_preview', (stream.name === this.settingState.preview)); + card.setState('is_backend', (stream.name === this.settingState.backend)); + card.setState('is_frontend', (stream.name === this.settingState.frontend)); + + if (card.state.is_backend && card.state.is_frontend) { + this.backendSlot.append(card); + this.querySelector('.civi-backend h3').innerText = ts('Current Theme (Backend + Frontend)'); + this.querySelector('.civi-frontend').hidden = true; + } + else if (card.state.is_backend) { + this.backendSlot.append(card); + this.querySelector('.civi-backend h3').innerText = ts('Current Backend Theme'); + } + else if (card.state.is_frontend) { + this.frontendSlot.append(card); + this.querySelector('.civi-frontend').hidden = false; + } + else { + // add to the list of other streams + const li = document.createElement('li'); + li.append(card); + this.ul.append(li); + } + }); + } + + openEditorDialog(streamName) { + // clear the dialog before opening + this.editorDialog.innerHTML = ''; + + const editor = document.createElement('civi-riverlea-stream-editor'); + editor.streamName = streamName; + editor.setStreamData(this.streams[streamName]); + + this.editorDialog.append(editor); + + // listen to editor events - close the dialog and refresh if needed + editor.addEventListener('reset', () => this.editorDialog.close()); + editor.addEventListener('save', () => this.editorDialog.close() || this.fetchAndRender()); + + this.editorDialog.showModal(); + } + + updateSetting(targetSetting, streamName) { + if (!['preview', 'backend', 'frontend'].includes(targetSetting)) { + console.warn('Unknown theme setting key to activate: ' + targetSetting); + return; + } + + this.settingState[targetSetting] = streamName; + + // update session for preview setting + if (targetSetting === 'preview') { + this.querySelectorAll('civi-riverlea-stream-card').forEach((card) => { + card.setState('is_preview', (card.streamName === streamName)); + }); + + CRM.riverlea.previewSession({ + streams: this.streams, + selected: streamName + }); + CRM.riverlea.previewer().load(); + } + + // send site setting to the server and update the card positions + if (targetSetting === 'backend' || targetSetting === 'frontend') { + return CRM.api4('RiverleaStream', 'activate', { + where: [['name', '=', streamName]], + backOrFront: targetSetting + }) + .then(() => this.render()); + } + + return Promise.resolve(); + } + + confirmThenUpdate(targetSetting, streamName, streamLabel) { + return CRM.confirm({ + message: ts(`Are you sure you want to set %1 as the %2 theme? This affects all site users.`, { + 1: streamLabel, + 2: targetSetting + }) + }) + .on('crmConfirm:yes', () => this.updateSetting(targetSetting, streamName)); + } + + } + + customElements.define('civi-riverlea-stream-list', CiviRiverleaStreamList); + + /** + * + * + */ + class CiviRiverleaStreamCard extends HTMLElement { + constructor() { + super(); + + // initialise state array + this.state = {}; + } + + connectedCallback() { + + this.streamList = this.closest('civi-riverlea-stream-list'); + + if (!this.streamList) { + console.warn('Please add civi-riverlea-stream-card inside a civi-riverlea-stream-list element.'); + return; + } + + if (!this.data) { + this.fetchAndRender(); + } + else { + this.render(); + } + + } + + setData(data) { + this.data = data; + } + + get streamName() { + return this.data.name; + } + + render() { + this.innerHTML = ` +
    +
    +

    ${this.data.label}

    +
    +
    + +
    +

    + ${ this.data.description ? this.data.description : '' } +

    + +
    + ${ ts('More info') } +
    +
    + +
    + `; + + this.renderDetailsArea(this.querySelector('.civi-riverlea-stream-details')); + + this.renderHeaderButtons(this.querySelector('.civi-riverlea-stream-header-buttons')); + this.renderPanelButtons(this.querySelector('.civi-riverlea-stream-panel-buttons')); + + this.rendered = true; + + this.renderState(); + } + + renderDetailsArea(container) { + const detailsFields = [ + { key: 'extension', label: ts('Extension') }, + { key: 'file_prefix', label: ts('File Prefix') }, + { key: 'css_file', label: ts('CSS File') }, + { key: 'css_file_dark', label: ts('Dark-mode CSS File') }, + { key: 'vars', label: ts('Variables') }, + { key: 'vars_dark', label: ts('Dark-mode Variables') }, + { key: 'custom_css', label: ts('Custom CSS') }, + { key: 'custom_css_dark', label: ts('Dark-mode Custom CSS') } + ]; + + detailsFields.forEach((field) => { + const value = this.data[field.key] ?? null; + + if (value) { + + const renderedValue = (typeof value === 'string') ? value : JSON.stringify(value); + + const detailItem = document.createElement('div'); + detailItem.innerHTML = ` + + ${renderedValue} + `; + container.append(detailItem); + } + }); + + // hide the details area if empty + container.hidden = !container.children.length; + } + + renderPanelButtons(container) { + const createButton = CRM.riverlea.createButton; + + // note: we stash these buttons as instance properties so they can be + // updated in renderState + this.setPreview = createButton('Preview', 'btn-set-preview', 'eye', () => this.streamList.updateSetting('preview', this.streamName)); + this.setBackend = createButton('Set for Backend', 'btn-set-backend', 'briefcase', () => this.streamList.confirmThenUpdate('backend', this.streamName, this.data.label)); + this.setFrontend = createButton('Set for Frontend', 'btn-set-frontend', 'shop', () => this.streamList.confirmThenUpdate('frontend', this.streamName, this.data.label)); + + container.append(this.setPreview, this.setBackend, this.setFrontend); + } + + renderHeaderButtons(container) { + const createButton = CRM.riverlea.createButton; + + const cloneBtn = createButton('Clone', 'btn-clone', 'copy', () => this.streamList.clone(this.streamName).then(() => CRM.alert(ts('Stream cloned'), '', 'success'))); + container.append(cloneBtn); + + if (!this.data.is_reserved) { + const editBtn = createButton('Edit', 'btn-update', 'pen', () => this.streamList.openEditorDialog(this.streamName, this.data)); + + const deleteBtn = createButton('Delete', 'btn-delete', 'trash', + () => CRM.confirm({ + message: ts(`Are you sure you want to delete %1?`, {1: this.data.label}) + }) + .on('crmConfirm:yes', () => this.streamList.delete(this.streamName)) + ); + + container.append(editBtn, deleteBtn); + } + + } + + setState(prop, value) { + this.state[prop] = value; + if (this.rendered) { + this.renderState(); + } + } + + renderState() { + const updateButtonState = (button, is_disabled) => { + button.disabled = is_disabled; + button.classList.toggle('btn-stream-selected', is_disabled); + }; + + updateButtonState(this.setPreview, this.state.is_preview); + updateButtonState(this.setBackend, this.state.is_backend); + updateButtonState(this.setFrontend, this.state.is_frontend); + + this.classList.toggle('is-set-preview', this.state.is_preview); + this.classList.toggle('is-set-backend', this.state.is_backend); + this.classList.toggle('is-set-frontend', this.state.is_frontend); + } + + fetchAndRender() { + CRM.api4('RiverleaStream', 'get', { + where: [['name', '=', this.streamName]], + }) + .then((records) => { + if (!records.length) { + throw new Error('Failed to refetch stream data for ' + this.streamName); + } + return records[0]; + }) + .then((record) => this.data = record) + .then(() => this.render()); + } + } + + customElements.define('civi-riverlea-stream-card', CiviRiverleaStreamCard); + +})(CRM); + + diff --git a/ext/riverlea/js/utils.js b/ext/riverlea/js/utils.js new file mode 100644 index 000000000000..b21a6384b247 --- /dev/null +++ b/ext/riverlea/js/utils.js @@ -0,0 +1,18 @@ +(function (CRM) { + + CRM.riverlea = CRM.riverlea || {}; + + // TODO: move to a general CRM util? + CRM.riverlea.createButton = (label, btnClass, icon, clickHandler) => { + const button = document.createElement('button'); + button.type = 'button'; + button.classList.add('btn', btnClass); + button.innerHTML = `${ts(label)}`; + button.onclick = clickHandler; + + return button; + }; + +})(CRM); + + From 26e69d31ba1ed33427cc3fb40c1ce6fff2b32dca Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 21:01:40 +0000 Subject: [PATCH 2/6] Riverlea - rename Riverlea Settings => Theme Settings, add stream list --- .../Civi/Riverlea/Page/ThemeAdmin.php | 59 +++++++++++++++++++ ext/riverlea/info.xml | 5 +- ...d.php => Navigation_ThemeSettings.mgd.php} | 11 ++-- ext/riverlea/settings/riverlea.setting.php | 34 +---------- .../Civi/Riverlea/Page/ThemeAdmin.tpl | 19 ++++++ ext/riverlea/xml/Menu/riverlea.xml | 15 +++++ 6 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 ext/riverlea/Civi/Riverlea/Page/ThemeAdmin.php rename ext/riverlea/managed/{navigationmenu.mgd.php => Navigation_ThemeSettings.mgd.php} (61%) create mode 100644 ext/riverlea/templates/Civi/Riverlea/Page/ThemeAdmin.tpl create mode 100644 ext/riverlea/xml/Menu/riverlea.xml diff --git a/ext/riverlea/Civi/Riverlea/Page/ThemeAdmin.php b/ext/riverlea/Civi/Riverlea/Page/ThemeAdmin.php new file mode 100644 index 000000000000..a599080ff577 --- /dev/null +++ b/ext/riverlea/Civi/Riverlea/Page/ThemeAdmin.php @@ -0,0 +1,59 @@ +addScriptFile(E::SHORT_NAME, 'js/utils.js', ['weight' => 100]); + $resources->addScriptFile(E::SHORT_NAME, 'js/editor.js', ['weight' => 200]); + $resources->addScriptFile(E::SHORT_NAME, 'js/stream-list.js', ['weight' => 200]); + $resources->addStyleFile(E::SHORT_NAME, 'css/stream-list.css'); + + if (!\Civi::service('riverlea.style_loader')->isActive()) { + \CRM_Core_Session::setStatus(E::ts('Stream previewer will not work whilst using a legacy theme'), ts('Stream Previews'), 'warning'); + } + + // get settings for the theme page + $allSettings = SettingsMetadata::getMetadata([], NULL, TRUE, FALSE, TRUE); + $themeSettings = array_filter($allSettings, fn ($setting) => isset($setting['settings_pages']['theme'])); + // sort by theme page weight from the meta + \usort($themeSettings, fn ($a, $b) => $a['settings_pages']['theme']['weight'] - $b['settings_pages']['theme']['weight']); + + foreach ($themeSettings as &$setting) { + $value = \Civi::settings()->get($setting['name']); + + // render option values if applicable + // use raw value if not a valid option + if ($setting['options']) { + $valueLabel = $setting['options'][$value] ?? E::ts("%1 [unrecognised option]", [1 => $value]); + } + else { + $valueLabel = $value; + } + + $setting['value'] = $value; + $setting['value_label'] = $valueLabel; + } + + $this->assign('settings', $themeSettings); + $this->assign('settingsFormUrl', \Civi::url('backend://civicrm/admin/settings/theme')); + + // when the settings form is submitted, the only way we have to refresh the values shown is by refreshing the whole page + // TODO: create a component to show settings values (with an endpoint that can render them like the above) and then + // refresh this component on submit + \Civi::resources()->addScript(' + CRM.$(function($) { + $(document).on("crmPopupFormSuccess", () => window.location.reload()); + }); + '); + + parent::run(); + } + +} diff --git a/ext/riverlea/info.xml b/ext/riverlea/info.xml index a31c4577e7ee..10b31feceabe 100755 --- a/ext/riverlea/info.xml +++ b/ext/riverlea/info.xml @@ -39,10 +39,13 @@ theme-php@1.0.0 setting-php@1.0.0 - setting-admin@1.0.1 mgd-php@2.0.0 scan-classes@1.0.0 entity-types-php@2.0.0 + afform-entity-php@1.0.0 + ang-php@1.0.0 + menu-xml@1.0.0 + smarty@1.0.3 CiviMix\Schema\Riverlea\AutomaticUpgrader diff --git a/ext/riverlea/managed/navigationmenu.mgd.php b/ext/riverlea/managed/Navigation_ThemeSettings.mgd.php similarity index 61% rename from ext/riverlea/managed/navigationmenu.mgd.php rename to ext/riverlea/managed/Navigation_ThemeSettings.mgd.php index 1ed3e54afc56..d21e7cc2715f 100644 --- a/ext/riverlea/managed/navigationmenu.mgd.php +++ b/ext/riverlea/managed/Navigation_ThemeSettings.mgd.php @@ -4,18 +4,17 @@ return [ [ - 'name' => 'riverlea_settings', + 'name' => 'Navigation_ThemeSettings', 'entity' => 'Navigation', 'cleanup' => 'always', 'update' => 'unmodified', 'params' => [ 'version' => 4, 'values' => [ - 'label' => E::ts('Riverlea Settings'), - 'name' => 'riverlea_settings', - 'url' => 'civicrm/admin/setting/riverlea', - 'permission' => 'administer riverlea', - 'permission_operator' => 'OR', + 'label' => E::ts('Theme Settings'), + 'name' => 'ThemeSettings', + 'url' => 'civicrm/admin/theme', + 'permission' => 'administer CiviCRM', 'parent_id.name' => 'Customize Data and Screens', 'is_active' => TRUE, 'has_separator' => 0, diff --git a/ext/riverlea/settings/riverlea.setting.php b/ext/riverlea/settings/riverlea.setting.php index 1b080340928e..979cc68983a5 100644 --- a/ext/riverlea/settings/riverlea.setting.php +++ b/ext/riverlea/settings/riverlea.setting.php @@ -20,9 +20,7 @@ 'dark' => E::ts('Always use dark mode'), ], 'settings_pages' => [ - 'riverlea' => ['weight' => 100], - // show alongside backend theme selector on Display settings page - 'display' => ['section' => 'theme', 'weight' => 20], + 'theme' => ['weight' => 110], ], ], 'riverlea_dark_mode_frontend' => [ @@ -42,35 +40,7 @@ 'dark' => E::ts('Always use dark mode'), ], 'settings_pages' => [ - 'riverlea' => ['weight' => 110], - // show alongside frontend theme selector on Display settings page - 'display' => ['section' => 'theme', 'weight' => 40], - ], - ], - 'riverlea_font_size' => [ - 'name' => 'riverlea_font_size', - 'group' => 'riverlea', - 'default' => '1', - 'type' => 'String', - 'html_type' => 'select', - 'add' => 1.0, - 'options' => [ - '0.75' => E::ts('Smallest'), - '0.875' => E::ts('Small'), - '1' => E::ts('Default'), - '1.125' => E::ts('Big'), - '1.5' => E::ts('Bigger'), - ], - 'on_change' => [ - '\\Civi\\Riverlea\\StyleLoader::onChangeFontsize', - ], - 'validate_callback' => '\\Civi\\Riverlea\\StyleLoader::validateFontsize', - 'title' => E::ts('Font size'), - 'is_domain' => 1, - 'is_contact' => 0, - 'help_text' => E::ts('For systems where 1rem = 16px (which is the default in RiverLea and all browsers) these sizes represent: Smallest 12px, Small 14px, Default 16px, Big 18px, Bigger 24px.'), - 'settings_pages' => [ - 'riverlea' => ['weight' => 500], + 'theme' => ['weight' => 210], ], ], ]; diff --git a/ext/riverlea/templates/Civi/Riverlea/Page/ThemeAdmin.tpl b/ext/riverlea/templates/Civi/Riverlea/Page/ThemeAdmin.tpl new file mode 100644 index 000000000000..4ad3662bc471 --- /dev/null +++ b/ext/riverlea/templates/Civi/Riverlea/Page/ThemeAdmin.tpl @@ -0,0 +1,19 @@ +

    {ts}Themes{/ts}

    + + + +

    {ts}Additional settings{/ts}

    + +
    + {foreach $settings as $setting} +
    +
    {$setting.title}
    +
    {$setting.value_label}
    +
    + {/foreach} +
    + + + + {ts}Edit{/ts} + diff --git a/ext/riverlea/xml/Menu/riverlea.xml b/ext/riverlea/xml/Menu/riverlea.xml new file mode 100644 index 000000000000..a3388cca25f3 --- /dev/null +++ b/ext/riverlea/xml/Menu/riverlea.xml @@ -0,0 +1,15 @@ + + + + civicrm/admin/theme + Civi\Riverlea\Page\ThemeAdmin + Theme Settings + administer CiviCRM + + + civicrm/admin/settings/theme + CRM_Admin_Form_Generic + Theme Settings + administer CiviCRM + + From e1ba5f950750c486d93a2762cda29cbf553329a2 Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 21:42:44 +0000 Subject: [PATCH 3/6] Riverlea - stream editor - restore curated options for font size --- ext/riverlea/js/editor.js | 43 +++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/ext/riverlea/js/editor.js b/ext/riverlea/js/editor.js index 64a641fac0ce..c45c6633f84f 100644 --- a/ext/riverlea/js/editor.js +++ b/ext/riverlea/js/editor.js @@ -129,7 +129,7 @@
    `; - const createVariableInput = (label, streamField, name, type, unit = null) => { + const createVariableInput = (label, streamField, name, type, unit = null, options = null) => { const el = document.createElement('civi-riverlea-stream-variable'); el.setAttribute('stream-field', streamField); el.setAttribute('name', name); @@ -138,6 +138,9 @@ if (unit) { el.setAttribute('unit', unit); } + if (options) { + el.options = options; + } return el; }; @@ -182,7 +185,13 @@ ); inputContainer.querySelector('.civi-riverlea-stream-size-inputs').append( - createVariableInput(ts('Font Size'), 'vars', '--crm-font-size', 'number', 'rem'), + createVariableInput(ts('Font Size'), 'vars', '--crm-font-size', 'select', 'rem', [ + {value: '0.75', label: ts('Smallest')}, + {value: '0.875', label: ts('Small')}, + {value: '1', label: ts('Default')}, + {value: '1.125', label: ts('Big')}, + {value: '1.5', label: ts('Biggest')}, + ]), createVariableInput(ts('Roundness'), 'vars', '--crm-l-radius', 'number', 'rem') ); @@ -252,7 +261,6 @@ if (this.unit) { val = `${val}${this.unit}`; - console.log(val); } // do the assignment @@ -263,11 +271,6 @@ delete this.editor.unsavedData[this.streamField][this.name]; } this.editor.refreshPreview(); -// else { -// // update preview sheet -// this.previewStyles.insertRule(`:root { ${this.name}: ${val} }`, this.previewStyles.cssRules.length); -// } - } get previewStyles() { @@ -293,16 +296,30 @@ return; } - const input = document.createElement('input'); - input.type = this.type; + let input = null; + + if (this.type === 'select') { + input = document.createElement('select'); + this.options.forEach((o) => { + const option = document.createElement('option'); + option.value = o.value; + option.innerText = o.label; + option.selected = (o.value === this.value); + input.append(option); + }); + } + else { + input = document.createElement('input'); + input.type = this.type; + input.value = this.value; + input.step = '0.1'; + } + input.name = `${this.editor.streamName}_${this.streamField}_${this.name}`; - input.value = this.value; input.onchange = () => { this.value = input.value; }; - input.step = '0.1'; - const clear = createButton(ts('Clear'), 'btn-clear', 'xmark', () => { this.value = null; this.render(); From c05466890e92bc05efe05850604025ec7ec1edf3 Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 21:58:50 +0000 Subject: [PATCH 4/6] Riverlea - restore dark mode editing --- ext/riverlea/css/stream-list.css | 23 ++++- ext/riverlea/js/editor.js | 154 +++++++++++++++++++------------ ext/riverlea/js/previewer.js | 8 +- 3 files changed, 118 insertions(+), 67 deletions(-) diff --git a/ext/riverlea/css/stream-list.css b/ext/riverlea/css/stream-list.css index b4d5ebb11213..eeb60c486808 100644 --- a/ext/riverlea/css/stream-list.css +++ b/ext/riverlea/css/stream-list.css @@ -11,18 +11,21 @@ civi-riverlea-stream-list ul { civi-riverlea-stream-card { display: block; } +civi-riverlea-stream-card.is-set-preview > .panel { + outline: 2px solid var(--crm-warning-color); +} civi-riverlea-stream-card .panel-heading { display: flex; justify-content: space-between; align-items: center; } civi-riverlea-stream-card .btn.btn-stream-selected { - background-color: var(--crm-c-success); - color: var(--crm-c-success-text); + background-color: var(--crm-success-color); + color: var(--crm-success-text-color); } civi-riverlea-stream-card .btn.btn-set-preview.btn-stream-selected { - background-color: var(--crm-c-warning); - color: var(--crm-c-warning-text); + background-color: var(--crm-warning-color); + color: var(--crm-warning-text-color); } civi-riverlea-stream-card .civi-riverlea-stream-details code { @@ -33,14 +36,26 @@ civi-riverlea-stream-list .civi-riverlea-stream-edit-dialog { width: 100%; } +civi-riverlea-stream-editor .civi-riverlea-stream-editor-preview-pane { + padding: 1rem; +} civi-riverlea-stream-editor iframe { width: 100%; height: 100%; + border: var(--crm-border); } civi-riverlea-stream-editor, civi-riverlea-stream-editor fieldset { display: flex; flex-direction: column; + gap: var(--crm-flex-gap); +} +civi-riverlea-stream-editor .civi-riverlea-stream-colors-header { + display: flex; + justify-content: space-between; +} +civi-riverlea-stream-editor .civi-riverlea-stream-colors-header h3 { + padding-left: 0; } civi-riverlea-stream-variable { diff --git a/ext/riverlea/js/editor.js b/ext/riverlea/js/editor.js index c45c6633f84f..7d933f249b46 100644 --- a/ext/riverlea/js/editor.js +++ b/ext/riverlea/js/editor.js @@ -12,34 +12,12 @@ } connectedCallback() { - // initialise basic internal structure - this.innerHTML = ` -
    -
    -
    - -
    -
    -
    -
    -
    - `; - - // add preview iframe - this.previewFrame = document.createElement('iframe'); - this.previewFrame.src = CRM.url(''); - this.querySelector('.civi-riverlea-stream-editor-preview-container').append(this.previewFrame); - - this.previewFrame.addEventListener('load', () => this.refreshPreview()); + this.render(); - // render the buttons - this.renderButtons(); - this.renderInputs(); + // switch between editing light/dark mode + this.editDarkMode = false; } - disconnectedCallback() { - delete this.previewFrame; - } fetchStreamData() { // initialise store for unsaved values @@ -72,7 +50,7 @@ reset() { this.unsavedData = {...this.data}; - this.renderInputs(); + this.renderEditPane(); this.refreshPreview(); this.dispatchEvent(new Event('reset')); } @@ -96,39 +74,81 @@ } // this ensures any css_file content for the stream is loaded in the frame - framePreview.render(this.unsavedData); + framePreview.render(this.unsavedData, this.editDarkMode ? 'dark' : 'light'); } - renderButtons() { - const createButton = CRM.riverlea.createButton; + render() { + // initialise basic internal structure + this.innerHTML = ` +
    - const saveButton = createButton(ts('Save'), 'btn-primary', 'save', () => this.save()); - saveButton.type = 'submit'; - const cancelButton = createButton(ts('Cancel'), 'btn-secondary', 'xmark', () => this.reset()); - cancelButton.type = 'submit'; +
    - this.querySelector('.civi-riverlea-stream-editor-buttons') - .append(saveButton, cancelButton); - } +
    + +
    - renderInputs() { - const inputContainer = this.querySelector('.civi-riverlea-stream-editor-input-container'); +
    - inputContainer.innerHTML = ` -

    ${this.data.label}

    +
    + `; -
    + this.previewFrame = this.querySelector('iframe'); + this.previewFrame.src = CRM.url(''); + this.previewFrame.addEventListener('load', () => this.refreshPreview()); + + this.editPane = this.querySelector('.civi-riverlea-stream-editor-edit-pane'); + this.renderEditPane(); -
    -

    ${this.darkMode ? ts('Dark-mode colors') : ts('Colors')}

    + this.renderButtons(); + } -
    + renderEditPane() { + this.editPane.innerHTML = ` +

    + +
    + +
    +

    + +
    +
    +
    `; + this.editPane.querySelector('h2').innerText = this.data.label; + + this.editPane.querySelector('.civi-riverlea-stream-colors-header h3').innerText = ts('Colors'); + + // render dark mode switcher + const darkModeToggle = this.editPane.querySelector('.civi-riverlea-stream-dark-toggle input'); + darkModeToggle.checked = this.editDarkMode; + darkModeToggle.onchange = () => { + this.editDarkMode = darkModeToggle.checked; + this.refreshPreview(); + this.toggleDarkMode(); + }; + + // ensure label is rendered and only one color fieldset is displayed before they are populated + this.toggleDarkMode(); + this.renderInputs(); + } + + toggleDarkMode() { + this.editPane.querySelector('.civi-riverlea-stream-dark-toggle span').innerText = this.editDarkMode ? ts('Dark mode') : ts('Light mode'); + this.editPane.querySelector('.civi-riverlea-stream-color-inputs-light').style.display = this.editDarkMode ? 'none' : null; + this.editPane.querySelector('.civi-riverlea-stream-color-inputs-dark').style.display = this.editDarkMode ? null : 'none'; + } + + renderInputs() { const createVariableInput = (label, streamField, name, type, unit = null, options = null) => { const el = document.createElement('civi-riverlea-stream-variable'); el.setAttribute('stream-field', streamField); @@ -167,24 +187,27 @@ return group; }; - inputContainer.querySelector('.civi-riverlea-stream-meta-inputs').append( + this.editPane.querySelector('.civi-riverlea-stream-meta-inputs').append( createTextInput(ts('Stream Name'), 'label'), createTextInput(ts('Description'), 'description', true) ); - const colorSchemeVarsTarget = 'vars'; // TODO: 'var_dark' - - inputContainer.querySelector('.civi-riverlea-stream-color-inputs').append( - createVariableInput(ts('Text'), colorSchemeVarsTarget, '--crm-text-color', 'color'), - createVariableInput(ts('Background'), colorSchemeVarsTarget, '--crm-container-bg-color', 'color'), - createVariableInput(ts('Page Background'), colorSchemeVarsTarget, '--crm-page-bg-color', 'color'), - createVariableInput(ts('Primary Highlight'), colorSchemeVarsTarget, '--crm-primary-color', 'color'), - createVariableInput(ts('Primary Text'), colorSchemeVarsTarget, '--crm-primary-text-color', 'color'), - createVariableInput(ts('Secondary Highlight'), colorSchemeVarsTarget, '--crm-secondary-text', 'color'), - createVariableInput(ts('Secondary Text'), colorSchemeVarsTarget, '--crm-secondary-text-color', 'color'), - ); - - inputContainer.querySelector('.civi-riverlea-stream-size-inputs').append( + // create parallel sets of inptus for light and dark mode + [ + {selector: '.civi-riverlea-stream-color-inputs-light', varsTarget: 'vars'}, + {selector: '.civi-riverlea-stream-color-inputs-dark', varsTarget: 'vars_dark'} + ].forEach((colorSet) => + this.editPane.querySelector(colorSet.selector).append( + createVariableInput(ts('Text'), colorSet.varsTarget, '--crm-text-color', 'color'), + createVariableInput(ts('Background'), colorSet.varsTarget, '--crm-container-bg-color', 'color'), + createVariableInput(ts('Page Background'), colorSet.varsTarget, '--crm-page-bg-color', 'color'), + createVariableInput(ts('Primary Highlight'), colorSet.varsTarget, '--crm-primary-color', 'color'), + createVariableInput(ts('Primary Text'), colorSet.varsTarget, '--crm-primary-text-color', 'color'), + createVariableInput(ts('Secondary Highlight'), colorSet.varsTarget, '--crm-secondary-text', 'color'), + createVariableInput(ts('Secondary Text'), colorSet.varsTarget, '--crm-secondary-text-color', 'color'), + )); + + this.editPane.querySelector('.civi-riverlea-stream-size-inputs').append( createVariableInput(ts('Font Size'), 'vars', '--crm-font-size', 'select', 'rem', [ {value: '0.75', label: ts('Smallest')}, {value: '0.875', label: ts('Small')}, @@ -195,11 +218,24 @@ createVariableInput(ts('Roundness'), 'vars', '--crm-l-radius', 'number', 'rem') ); - inputContainer.querySelector('.civi-riverlea-stream-custom-inputs').append( + this.editPane.querySelector('.civi-riverlea-stream-custom-inputs').append( createTextInput(ts('Custom CSS'), 'custom_css', true), createTextInput(ts('Darkmode Custom CSS'), 'custom_css_dark', true) ); } + + renderButtons() { + const createButton = CRM.riverlea.createButton; + + const saveButton = createButton(ts('Save'), 'btn-primary', 'save', () => this.save()); + saveButton.type = 'submit'; + const cancelButton = createButton(ts('Cancel'), 'btn-secondary', 'xmark', () => this.reset()); + cancelButton.type = 'submit'; + + this.querySelector('.civi-riverlea-stream-editor-buttons') + .append(saveButton, cancelButton); + } + } customElements.define('civi-riverlea-stream-editor', CiviRiverleaStreamEditor); diff --git a/ext/riverlea/js/previewer.js b/ext/riverlea/js/previewer.js index f358ff7f323c..7b4da15c430a 100644 --- a/ext/riverlea/js/previewer.js +++ b/ext/riverlea/js/previewer.js @@ -67,14 +67,14 @@ .then((content) => this.loadStyles(content)); } - async render(streamData) { + async render(streamData, darkMode = 'inherit') { await this.fetchCoreDarkRules(); - const styles = this.renderCss(streamData); + const styles = this.renderCss(streamData, darkMode); this.loadStyles(styles); } - renderCss(streamData) { + renderCss(streamData, darkMode = 'inherit') { if (!streamData) { return ''; } @@ -110,7 +110,7 @@ ${customCssDark} `; - switch (this.darkModeSetting) { + switch (darkMode) { case 'light': return lightRules; From 1b6c431d689ffc895b3a871db15983a0404e04e4 Mon Sep 17 00:00:00 2001 From: benjamin Date: Fri, 30 Jan 2026 16:05:48 +0000 Subject: [PATCH 5/6] Riverlea theme settings - graceful handling for non-RL selection --- ext/riverlea/js/stream-list.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ext/riverlea/js/stream-list.js b/ext/riverlea/js/stream-list.js index 3ed4a0dc43d9..b2ac498476cf 100644 --- a/ext/riverlea/js/stream-list.js +++ b/ext/riverlea/js/stream-list.js @@ -70,8 +70,10 @@ fetchSettingState() { this.settingState = {}; - const previewSession = CRM.riverlea.previewSession(); - this.settingState.preview = previewSession ? previewSession.selected : null; + if (CRM?.riverlea.previewSession) { + const previewSession = CRM.riverlea.previewSession(); + this.settingState.preview = previewSession ? previewSession.selected : null; + } return CRM.api4('Setting', 'get', { select: ['theme_backend', 'theme_frontend'] }) .then((results) => results.forEach((record) => { @@ -180,6 +182,13 @@ this.ul.append(li); } }); + + if (!this.backendSlot.hasChildNodes()) { + this.backendSlot.innerText = ts('Backend theme is currently set to non-Riverlea theme: %1', {1: this.settingState.backend}); + } + if (!this.frontendSlot.hasChildNodes()) { + this.frontendSlot.innerText = ts('Frontend theme is currently set to non-Riverlea theme: %1', {1: this.settingState.frontend}); + } } openEditorDialog(streamName) { From e559a75853ef45f77a5b9a0761ec5838bb75888d Mon Sep 17 00:00:00 2001 From: benjamin Date: Fri, 30 Jan 2026 16:20:52 +0000 Subject: [PATCH 6/6] Riverlea editor - fix crash if try to switch before the preview is loaded --- ext/riverlea/js/editor.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ext/riverlea/js/editor.js b/ext/riverlea/js/editor.js index 7d933f249b46..74da36d05671 100644 --- a/ext/riverlea/js/editor.js +++ b/ext/riverlea/js/editor.js @@ -57,20 +57,22 @@ refreshPreview() { const previewDocument = this.previewFrame.contentDocument; - if (!previewDocument) { + const previewBody = previewDocument?.querySelector('body'); + + if (!previewBody) { // this can happen, it's usually not a problem - console.debug('Editor preview frame is not loaded'); + console.debug('Editor preview frame is not loaded yet'); return; } // remove any session preview selector - previewDocument.querySelectorAll('civi-riverlea-preview-selector').forEach((el) => el.remove()); + previewBody.querySelectorAll('civi-riverlea-preview-selector').forEach((el) => el.remove()); - let framePreview = previewDocument.querySelector('civi-riverlea-stream-preview'); + let framePreview = previewBody.querySelector('civi-riverlea-stream-preview'); if (!framePreview) { framePreview = previewDocument.createElement('civi-riverlea-stream-preview'); - previewDocument.querySelector('body').append(framePreview); + previewBody.append(framePreview); } // this ensures any css_file content for the stream is loaded in the frame @@ -133,8 +135,8 @@ darkModeToggle.checked = this.editDarkMode; darkModeToggle.onchange = () => { this.editDarkMode = darkModeToggle.checked; - this.refreshPreview(); this.toggleDarkMode(); + this.refreshPreview(); }; // ensure label is rendered and only one color fieldset is displayed before they are populated