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/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/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..eeb60c486808 --- /dev/null +++ b/ext/riverlea/css/stream-list.css @@ -0,0 +1,75 @@ +/* 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.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-success-color); + color: var(--crm-success-text-color); +} +civi-riverlea-stream-card .btn.btn-set-preview.btn-stream-selected { + background-color: var(--crm-warning-color); + color: var(--crm-warning-text-color); +} + +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 .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 { + 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/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/js/editor.js b/ext/riverlea/js/editor.js new file mode 100644 index 000000000000..74da36d05671 --- /dev/null +++ b/ext/riverlea/js/editor.js @@ -0,0 +1,377 @@ +(function (CRM) { + + /** + * + * + */ + class CiviRiverleaStreamEditor extends HTMLElement { + constructor() { + super(); + + this.data = {}; + } + + connectedCallback() { + this.render(); + + // switch between editing light/dark mode + this.editDarkMode = false; + } + + + 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.renderEditPane(); + this.refreshPreview(); + this.dispatchEvent(new Event('reset')); + } + + refreshPreview() { + const previewDocument = this.previewFrame.contentDocument; + const previewBody = previewDocument?.querySelector('body'); + + if (!previewBody) { + // this can happen, it's usually not a problem + console.debug('Editor preview frame is not loaded yet'); + return; + } + + // remove any session preview selector + previewBody.querySelectorAll('civi-riverlea-preview-selector').forEach((el) => el.remove()); + + let framePreview = previewBody.querySelector('civi-riverlea-stream-preview'); + + if (!framePreview) { + framePreview = previewDocument.createElement('civi-riverlea-stream-preview'); + previewBody.append(framePreview); + } + + // this ensures any css_file content for the stream is loaded in the frame + framePreview.render(this.unsavedData, this.editDarkMode ? 'dark' : 'light'); + } + + render() { + // initialise basic internal structure + this.innerHTML = ` +
+ +
+ +
+ +
+ +
+ +
+ `; + + 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.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.toggleDarkMode(); + this.refreshPreview(); + }; + + // 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); + el.setAttribute('name', name); + el.setAttribute('type', type); + el.setAttribute('label', label); + if (unit) { + el.setAttribute('unit', unit); + } + if (options) { + el.options = options; + } + + 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; + }; + + this.editPane.querySelector('.civi-riverlea-stream-meta-inputs').append( + createTextInput(ts('Stream Name'), 'label'), + createTextInput(ts('Description'), 'description', true) + ); + + // 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')}, + {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') + ); + + 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); + + /** + * + * + */ + 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}`; + } + + // 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(); + } + + 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; + } + + 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.onchange = () => { + this.value = input.value; + }; + + 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..7b4da15c430a --- /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, darkMode = 'inherit') { + await this.fetchCoreDarkRules(); + const styles = this.renderCss(streamData, darkMode); + + this.loadStyles(styles); + } + + renderCss(streamData, darkMode = 'inherit') { + 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 (darkMode) { + 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..b2ac498476cf --- /dev/null +++ b/ext/riverlea/js/stream-list.js @@ -0,0 +1,435 @@ +(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 = {}; + + 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) => { + 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); + } + }); + + 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) { + // 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); + + 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 + +