diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/snippets | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website/static/src/snippets')
83 files changed, 5597 insertions, 0 deletions
diff --git a/addons/website/static/src/snippets/s_alert/000.scss b/addons/website/static/src/snippets/s_alert/000.scss new file mode 100644 index 00000000..a44d1d2d --- /dev/null +++ b/addons/website/static/src/snippets/s_alert/000.scss @@ -0,0 +1,30 @@ + +.s_alert { + margin: $grid-gutter-width/2 0; + border: $alert-border-width solid; + border-radius: $alert-border-radius; + p, ul, ol { + &:last-child { + margin-bottom: 0; + } + } + &_sm { + padding: $grid-gutter-width/3; + font-size: $font-size-sm; + } + &_md { + padding: $grid-gutter-width/2; + font-size: $font-size-base; + } + &_lg { + padding: $grid-gutter-width; + font-size: $font-size-lg; + } + &_icon { + float: left; + margin-right: 10px; + } + &_content { + overflow: hidden; + } +} diff --git a/addons/website/static/src/snippets/s_badge/000.scss b/addons/website/static/src/snippets/s_badge/000.scss new file mode 100644 index 00000000..acdb811e --- /dev/null +++ b/addons/website/static/src/snippets/s_badge/000.scss @@ -0,0 +1,10 @@ + +.s_badge { + padding: $s-badge-padding; + margin: $s-badge-margin; + border-radius: if($s-badge-border-radius != null, $s-badge-border-radius, $badge-border-radius); + font-size: $font-size-sm; + .fa { + margin: $s-badge-i-margin; + } +} diff --git a/addons/website/static/src/snippets/s_badge/000_variables.scss b/addons/website/static/src/snippets/s_badge/000_variables.scss new file mode 100644 index 00000000..b6bc1929 --- /dev/null +++ b/addons/website/static/src/snippets/s_badge/000_variables.scss @@ -0,0 +1,4 @@ +$s-badge-border-radius: null; +$s-badge-padding: .5rem; +$s-badge-margin: .5rem .5rem .5rem 0; +$s-badge-i-margin: 0 .3rem 0 0; diff --git a/addons/website/static/src/snippets/s_blockquote/000.scss b/addons/website/static/src/snippets/s_blockquote/000.scss new file mode 100644 index 00000000..a41d1f27 --- /dev/null +++ b/addons/website/static/src/snippets/s_blockquote/000.scss @@ -0,0 +1,73 @@ +.s_blockquote { + // Reset + border: 0; + padding: 0; + .s_blockquote_icon { + font-size: $font-size-base; + } + .s_blockquote_author { + opacity: .75; + } + // Classic + &.s_blockquote_classic { + .s_blockquote_icon { + float: left; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + &.float-right { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + } + } + .s_blockquote_content { + overflow: hidden; + padding: $spacer * 1.5; + .blockquote-footer { + &::before { + content: ''; + } + .s_blockquote_avatar { + max-height: $spacer * 2.5; + } + } + } + } + // Cover + &.s_blockquote_cover { + text-align: center; + .s_blockquote_icon { + position: relative; + z-index: 1; + float: none; + margin-bottom: -$spacer * 1.5; + } + p:last-of-type { + margin-bottom: $spacer * .5; + } + .s_blockquote_content, .s_blockquote_filter { // s_blockquote_filter is there for compatibility + padding: $spacer * 3 $spacer * 2 $spacer * 2; + } + // Compatibility + .s_blockquote_filter { + margin: $spacer * -3 $spacer * -2 $spacer * -2; + } + .quote_char { + margin: $spacer * 2 0 $spacer 0; + & ~ .blockquote-footer { + padding-bottom: $spacer * 2; + } + } + } + // Minimalist + &.s_blockquote_minimalist { + border-left: 5px solid; + border-color: o-color('secondary'); + .s_blockquote_content { + padding: $spacer; + @include border-right-radius($border-radius); + p:last-of-type { + margin-bottom: 0; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_blockquote/options.js b/addons/website/static/src/snippets/s_blockquote/options.js new file mode 100644 index 00000000..46748f15 --- /dev/null +++ b/addons/website/static/src/snippets/s_blockquote/options.js @@ -0,0 +1,46 @@ +odoo.define('website.s_blockquote_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.Blockquote = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change blockquote design. + * + * @see this.selectClass for parameters + */ + display: function (previewMode, widgetValue, params) { + + // Classic + this.$target.find('.s_blockquote_avatar').toggleClass('d-none', widgetValue !== 'classic'); + + // Cover + const $blockquote = this.$target.find('.s_blockquote_content'); + if (widgetValue === 'cover') { + $blockquote.css({"background-image": "url('/web/image/website.s_blockquote_cover_default_image')"}); + $blockquote.css({"background-position": "50% 50%"}); + $blockquote.addClass('oe_img_bg'); + if (!$blockquote.find('.o_we_bg_filter').length) { + const bgFilterEl = document.createElement('div'); + bgFilterEl.classList.add('o_we_bg_filter', 'bg-white-50'); + $blockquote.prepend(bgFilterEl); + } + } else { + $blockquote.css({"background-image": ""}); + $blockquote.css({"background-position": ""}); + $blockquote.removeClass('oe_img_bg'); + $blockquote.find('.o_we_bg_filter').remove(); + $blockquote.find('.s_blockquote_filter').contents().unwrap(); // Compatibility + } + + // Minimalist + this.$target.find('.s_blockquote_icon').toggleClass('d-none', widgetValue === 'minimalist'); + this.$target.find('footer').toggleClass('d-none', widgetValue === 'minimalist'); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_btn/000.scss b/addons/website/static/src/snippets/s_btn/000.scss new file mode 100644 index 00000000..4e5a3215 --- /dev/null +++ b/addons/website/static/src/snippets/s_btn/000.scss @@ -0,0 +1,6 @@ + +.s_btn { + .btn + .btn { + margin-left: .75rem; + } +} diff --git a/addons/website/static/src/snippets/s_card/000.scss b/addons/website/static/src/snippets/s_card/000.scss new file mode 100644 index 00000000..436d9a70 --- /dev/null +++ b/addons/website/static/src/snippets/s_card/000.scss @@ -0,0 +1,12 @@ + +.s_card { + margin: $grid-gutter-width/2 0; + .card-body { + // color: initial; + p, ul, ol { + &:last-child { + margin-bottom: 0; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_chart/000.js b/addons/website/static/src/snippets/s_chart/000.js new file mode 100644 index 00000000..2c67bd03 --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/000.js @@ -0,0 +1,142 @@ +odoo.define('website.s_chart', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); +const weUtils = require('web_editor.utils'); + +const ChartWidget = publicWidget.Widget.extend({ + selector: '.s_chart', + disabledInEditableMode: false, + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + + /** + * @override + * @param {Object} parent + * @param {Object} options The default value of the chartbar. + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.style = window.getComputedStyle(document.documentElement); + }, + /** + * @override + */ + start: function () { + // Convert Theme colors to css color + const data = JSON.parse(this.el.dataset.data); + data.datasets.forEach(el => { + if (Array.isArray(el.backgroundColor)) { + el.backgroundColor = el.backgroundColor.map(el => this._convertToCssColor(el)); + el.borderColor = el.borderColor.map(el => this._convertToCssColor(el)); + } else { + el.backgroundColor = this._convertToCssColor(el.backgroundColor); + el.borderColor = this._convertToCssColor(el.borderColor); + } + el.borderWidth = this.el.dataset.borderWidth; + }); + + // Make chart data + const chartData = { + type: this.el.dataset.type, + data: data, + options: { + legend: { + display: this.el.dataset.legendPosition !== 'none', + position: this.el.dataset.legendPosition, + }, + tooltips: { + enabled: this.el.dataset.tooltipDisplay === 'true', + }, + title: { + display: !!this.el.dataset.title, + text: this.el.dataset.title, + }, + }, + }; + + // Add type specific options + if (this.el.dataset.type === 'radar') { + chartData.options.scale = { + ticks: { + beginAtZero: true, + } + }; + } else if (['pie', 'doughnut'].includes(this.el.dataset.type)) { + chartData.options.tooltips.callbacks = { + label: (tooltipItem, data) => { + const label = data.datasets[tooltipItem.datasetIndex].label; + const secondLabel = data.labels[tooltipItem.index]; + let final = label; + if (label) { + if (secondLabel) { + final = label + ' - ' + secondLabel; + } + } else if (secondLabel) { + final = secondLabel; + } + return final + ':' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; + }, + }; + } else { + chartData.options.scales = { + xAxes: [{ + stacked: this.el.dataset.stacked === 'true', + ticks: { + beginAtZero: true + }, + }], + yAxes: [{ + stacked: this.el.dataset.stacked === 'true', + ticks: { + beginAtZero: true + }, + }], + }; + } + + // Disable animation in edit mode + if (this.editableMode) { + chartData.options.animation = { + duration: 0, + }; + } + + const canvas = this.el.querySelector('canvas'); + this.chart = new window.Chart(canvas, chartData); + return this._super.apply(this, arguments); + }, + /** + * @override + * Discard all library changes to reset the state of the Html. + */ + destroy: function () { + if (this.chart) { // The widget can be destroyed before start has completed + this.chart.destroy(); + this.el.querySelectorAll('.chartjs-size-monitor').forEach(el => el.remove()); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} color A css color or theme color string + * @returns {string} Css color + */ + _convertToCssColor: function (color) { + if (!color) { + return 'transparent'; + } + return weUtils.getCSSVariableValue(color, this.style) || color; + }, +}); + +publicWidget.registry.chart = ChartWidget; + +return ChartWidget; +}); diff --git a/addons/website/static/src/snippets/s_chart/options.js b/addons/website/static/src/snippets/s_chart/options.js new file mode 100644 index 00000000..fe59cedf --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/options.js @@ -0,0 +1,477 @@ +odoo.define('website.s_chart_options', function (require) { +'use strict'; + +var core = require('web.core'); +const {ColorpickerWidget} = require('web.Colorpicker'); +var options = require('web_editor.snippets.options'); +const weUtils = require('web_editor.utils'); + +var _t = core._t; + +options.registry.InnerChart = options.Class.extend({ + custom_events: _.extend({}, options.Class.prototype.custom_events, { + 'get_custom_colors': '_onGetCustomColors', + }), + events: _.extend({}, options.Class.prototype.events, { + 'click we-button.add_column': '_onAddColumnClick', + 'click we-button.add_row': '_onAddRowClick', + 'click we-button.o_we_matrix_remove_col': '_onRemoveColumnClick', + 'click we-button.o_we_matrix_remove_row': '_onRemoveRowClick', + 'blur we-matrix input': '_onMatrixInputFocusOut', + 'focus we-matrix input': '_onMatrixInputFocus', + }), + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5']; + this.style = window.getComputedStyle(document.documentElement); + }, + /** + * @override + */ + start: function () { + this.backSelectEl = this.el.querySelector('[data-name="chart_bg_color_opt"]'); + this.borderSelectEl = this.el.querySelector('[data-name="chart_border_color_opt"]'); + + // Build matrix content + this.tableEl = this.el.querySelector('we-matrix table'); + const data = JSON.parse(this.$target[0].dataset.data); + data.labels.forEach(el => { + this._addRow(el); + }); + data.datasets.forEach((el, i) => { + if (this._isPieChart()) { + // Add header colors in case the user changes the type of graph + const headerBackgroundColor = this.themeArray[i] || this._randomColor(); + const headerBorderColor = this.themeArray[i] || this._randomColor(); + this._addColumn(el.label, el.data, headerBackgroundColor, headerBorderColor, el.backgroundColor, el.borderColor); + } else { + this._addColumn(el.label, el.data, el.backgroundColor, el.borderColor); + } + }); + this._displayRemoveColButton(); + this._displayRemoveRowButton(); + this._setDefaultSelectedInput(); + return this._super(...arguments); + }, + /** + * @override + */ + updateUI: async function () { + // Selected input might not be in dom anymore if col/row removed + // Done before _super because _computeWidgetState of colorChange + if (!this.lastEditableSelectedInput.closest('table') || this.colorPaletteSelectedInput && !this.colorPaletteSelectedInput.closest('table')) { + this._setDefaultSelectedInput(); + } + + await this._super(...arguments); + + // prevent the columns from becoming too small. + this.tableEl.classList.toggle('o_we_matrix_five_col', this.tableEl.querySelectorAll('tr:first-child th').length > 5); + + this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Color") : _t("Dataset Color"); + this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border") : _t("Dataset Border"); + + // Dataset/Cell color + this.tableEl.querySelectorAll('input').forEach(el => el.style.border = ''); + const selector = this._isPieChart() ? 'td input' : 'tr:first-child input'; + this.tableEl.querySelectorAll(selector).forEach(el => { + const color = el.dataset.backgroundColor || el.dataset.borderColor; + if (color) { + el.style.border = '2px solid'; + el.style.borderColor = ColorpickerWidget.isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style); + } + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Set the color on the selected input. + */ + colorChange: async function (previewMode, widgetValue, params) { + if (widgetValue) { + this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue; + } else { + delete this.colorPaletteSelectedInput.dataset[params.attributeName]; + } + await this._reloadGraph(); + // To focus back the input that is edited we have to wait for the color + // picker to be fully reloaded. + await new Promise(resolve => setTimeout(() => { + this.lastEditableSelectedInput.focus(); + resolve(); + })); + }, + /** + * @override + */ + selectDataAttribute: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + // Data might change if going from or to a pieChart. + if (params.attributeName === 'type') { + this._setDefaultSelectedInput(); + await this._reloadGraph(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'colorChange') { + return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || ''; + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + switch (widgetName) { + case 'stacked_chart_opt': { + return this._getColumnCount() > 1; + } + case 'chart_bg_color_opt': + case 'chart_border_color_opt': { + return !!this.colorPaletteSelectedInput; + } + } + return this._super(...arguments); + }, + /** + * Sets and reloads the data on the canvas if it has changed. + * Used in matrix related method. + * + * @private + */ + _reloadGraph: async function () { + const jsonValue = this._matrixToChartData(); + if (this.$target[0].dataset.data !== jsonValue) { + this.$target[0].dataset.data = jsonValue; + await this._refreshPublicWidgets(); + } + }, + /** + * Return a stringifyed chart.js data object from the matrix + * Pie charts have one color per data while other charts have one color per dataset. + * + * @private + */ + _matrixToChartData: function () { + const data = { + labels: [], + datasets: [], + }; + this.tableEl.querySelectorAll('tr:first-child input').forEach(el => { + data.datasets.push({ + label: el.value || '', + data: [], + backgroundColor: this._isPieChart() ? [] : el.dataset.backgroundColor || '', + borderColor: this._isPieChart() ? [] : el.dataset.borderColor || '', + }); + }); + this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el) => { + const title = el.querySelector('th input').value || ''; + data.labels.push(title); + el.querySelectorAll('td input').forEach((el, i) => { + data.datasets[i].data.push(el.value || 0); + if (this._isPieChart()) { + data.datasets[i].backgroundColor.push(el.dataset.backgroundColor || ''); + data.datasets[i].borderColor.push(el.dataset.borderColor || ''); + } + }); + }); + return JSON.stringify(data); + }, + /** + * Return a td containing a we-button with minus icon + * + * @param {...string} classes Classes to add to the we-button + * @returns {HTMLElement} + */ + _makeDeleteButton: function (...classes) { + const rmbuttonEl = options.buildElement('we-button', null, { + classes: ['o_we_text_danger', 'o_we_link', 'fa', 'fa-fw', 'fa-minus', ...classes], + }); + const newEl = document.createElement('td'); + newEl.appendChild(rmbuttonEl); + return newEl; + }, + /** + * Add a column to the matrix + * The th (dataset label) of a column hold the colors for the entire dataset if the graph is not a pie chart + * If the graph is a pie chart the color of the td (data) are used. + * + * @private + * @param {String} title The title of the column + * @param {Array} values The values of the column input + * @param {String} heardeBackgroundColor The background color of the dataset + * @param {String} headerBorderColor The border color of the dataset + * @param {string[]} cellBackgroundColors The background colors of the datas inputs, random color if missing + * @param {string[]} cellBorderColors The border color of the datas inputs, no color if missing + */ + _addColumn: function (title, values, heardeBackgroundColor, headerBorderColor, cellBackgroundColors = [], cellBorderColors = []) { + const firstRow = this.tableEl.querySelector('tr:first-child'); + const headerInput = this._makeCell('th', title, heardeBackgroundColor, headerBorderColor); + firstRow.insertBefore(headerInput, firstRow.lastElementChild); + + this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el, i) => { + const newCell = this._makeCell('td', values ? values[i] : null, cellBackgroundColors[i] || this._randomColor(), cellBorderColors[i - 1]); + el.insertBefore(newCell, el.lastElementChild); + }); + + const lastRow = this.tableEl.querySelector('tr:last-child'); + const removeButton = this._makeDeleteButton('o_we_matrix_remove_col'); + lastRow.appendChild(removeButton); + }, + /** + * Add a row to the matrix + * The background color of the datas are random + * + * @private + * @param {String} tilte The title of the row + */ + _addRow: function (tilte) { + const trEl = document.createElement('tr'); + trEl.appendChild(this._makeCell('th', tilte)); + this.tableEl.querySelectorAll('tr:first-child input').forEach(() => { + trEl.appendChild(this._makeCell('td', null, this._randomColor())); + }); + trEl.appendChild(this._makeDeleteButton('o_we_matrix_remove_row')); + const tbody = this.tableEl.querySelector('tbody'); + tbody.insertBefore(trEl, tbody.lastElementChild); + }, + /** + * @private + * @param {string} tag tag of the HTML Element (td/th) + * @param {string} value The current value of the cell input + * @param {string} backgroundColor The background Color of the data on the graph + * @param {string} borderColor The border Color of the the data on the graph + * @returns {HTMLElement} + */ + _makeCell: function (tag, value, backgroundColor, borderColor) { + const newEl = document.createElement(tag); + const contentEl = document.createElement('input'); + contentEl.type = 'text'; + contentEl.value = value || ''; + if (backgroundColor) { + contentEl.dataset.backgroundColor = backgroundColor; + } + if (borderColor) { + contentEl.dataset.borderColor = borderColor; + } + newEl.appendChild(contentEl); + return newEl; + }, + /** + * Display the remove button coresponding to the colIndex + * + * @private + * @param {Int} colIndex Can be undefined, if so the last remove button of the column will be shown + */ + _displayRemoveColButton: function (colIndex) { + if (this._getColumnCount() > 1) { + this._displayRemoveButton(colIndex, 'o_we_matrix_remove_col'); + } + }, + /** + * Display the remove button coresponding to the rowIndex + * + * @private + * @param {Int} rowIndex Can be undefined, if so the last remove button of the row will be shown + */ + _displayRemoveRowButton: function (rowIndex) { + //Nbr of row minus header and button + const rowCount = this.tableEl.rows.length - 2; + if (rowCount > 1) { + this._displayRemoveButton(rowIndex, 'o_we_matrix_remove_row'); + } + }, + /** + * @private + * @param {Int} tdIndex Can be undefined, if so the last remove button will be shown + * @param {String} btnClass Either o_we_matrix_remove_col or o_we_matrix_remove_row + */ + _displayRemoveButton: function (tdIndex, btnClass) { + const removeBtn = this.tableEl.querySelectorAll(`td we-button.${btnClass}`); + removeBtn.forEach(el => el.style.display = ''); //hide all + const index = tdIndex < removeBtn.length ? tdIndex : removeBtn.length - 1; + removeBtn[index].style.display = 'inline-block'; + }, + /** + * @private + * @return {boolean} + */ + _isPieChart: function () { + return ['pie', 'doughnut'].includes(this.$target[0].dataset.type); + }, + /** + * Return the number of column minus header and button + * @private + * @return {integer} + */ + _getColumnCount: function () { + return this.tableEl.rows[0].cells.length - 2; + }, + /** + * Select the first data input + * + * @private + */ + _setDefaultSelectedInput: function () { + this.lastEditableSelectedInput = this.tableEl.querySelector('td input'); + if (this._isPieChart()) { + this.colorPaletteSelectedInput = this.lastEditableSelectedInput; + } else { + this.colorPaletteSelectedInput = this.tableEl.querySelector('th input'); + } + }, + /** + * Return a random hexadecimal color. + * + * @private + * @return {string} + */ + _randomColor: function () { + return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Used by colorPalette to retrieve the custom colors used on the chart + * Make an array with all the custom colors used on the chart + * and apply it to the onSuccess method provided by the trigger_up. + * + * @private + */ + _onGetCustomColors: function (ev) { + const data = JSON.parse(this.$target[0].dataset.data || ''); + let customColors = []; + data.datasets.forEach(el => { + if (this._isPieChart()) { + customColors = customColors.concat(el.backgroundColor).concat(el.borderColor); + } else { + customColors.push(el.backgroundColor); + customColors.push(el.borderColor); + } + }); + customColors = customColors.filter((el, i, array) => { + return !weUtils.getCSSVariableValue(el, this.style) && array.indexOf(el) === i && el !== ''; // unique non class not transparent + }); + ev.data.onSuccess(customColors); + }, + /** + * Add a row at the end of the matrix and display it's remove button + * Choose the color of the column from the theme array or a random color if they are already used + * + * @private + */ + _onAddColumnClick: function () { + const usedColor = Array.from(this.tableEl.querySelectorAll('tr:first-child input')).map(el => el.dataset.backgroundColor); + const color = this.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor(); + this._addColumn(null, null, color, color); + this._reloadGraph().then(() => { + this._displayRemoveColButton(); + this.updateUI(); + }); + }, + /** + * Add a column at the end of the matrix and display it's remove button + * + * @private + */ + _onAddRowClick: function () { + this._addRow(); + this._reloadGraph().then(() => { + this._displayRemoveRowButton(); + this.updateUI(); + }); + }, + /** + * Remove the column and show the remove button of the next column or the last if no next. + * + * @private + * @param {Event} ev + */ + _onRemoveColumnClick: function (ev) { + const cell = ev.currentTarget.parentElement; + const cellIndex = cell.cellIndex; + this.tableEl.querySelectorAll('tr').forEach((el) => { + el.children[cellIndex].remove(); + }); + this._displayRemoveColButton(cellIndex - 1); + this._reloadGraph().then(() => { + this.updateUI(); + }); + }, + /** + * Remove the row and show the remove button of the next row or the last if no next. + * + * @private + * @param {Event} ev + */ + _onRemoveRowClick: function (ev) { + const row = ev.currentTarget.parentElement.parentElement; + const rowIndex = row.rowIndex; + row.remove(); + this._displayRemoveRowButton(rowIndex - 1); + this._reloadGraph().then(() => { + this.updateUI(); + }); + }, + /** + * @private + * @param {Event} ev + */ + _onMatrixInputFocusOut: function (ev) { + // Sometimes, an input is focusout for internal reason (like an undo + // recording) then focused again manually in the same JS stack + // execution. In that case, the blur should not trigger an option + // selection as the user did not leave the input. We thus defer the blur + // handling to then check that the target is indeed still blurred before + // executing the actual option selection. + setTimeout(() => { + if (ev.currentTarget === document.activeElement) { + return; + } + this._reloadGraph(); + }); + }, + /** + * Set the selected cell/header and display the related remove button + * + * @private + * @param {Event} ev + */ + _onMatrixInputFocus: function (ev) { + this.lastEditableSelectedInput = ev.target; + const col = ev.target.parentElement.cellIndex; + const row = ev.target.parentElement.parentElement.rowIndex; + if (this._isPieChart()) { + this.colorPaletteSelectedInput = ev.target.parentNode.tagName === 'TD' ? ev.target : null; + } else { + this.colorPaletteSelectedInput = this.tableEl.querySelector(`tr:first-child th:nth-of-type(${col + 1}) input`); + } + if (col > 0) { + this._displayRemoveColButton(col - 1); + } + if (row > 0) { + this._displayRemoveRowButton(row - 1); + } + this.updateUI(); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_color_blocks_2/000.scss b/addons/website/static/src/snippets/s_color_blocks_2/000.scss new file mode 100644 index 00000000..555cab58 --- /dev/null +++ b/addons/website/static/src/snippets/s_color_blocks_2/000.scss @@ -0,0 +1,36 @@ +.s_color_blocks_2 { + // Needed to be able to stretch the inner container so that + // the snippet works with the 50% and 100% height + &.o_half_screen_height, &.o_full_screen_height { + > :first-child { // container + &, > .row { + min-height: inherit; + } + } + } + .row { + display: flex; + flex-flow: row wrap; + + // Fix for safari browser as it 'supports' flex but not with the right + // behavior + &::before, &::after { + width: 0; + } + } + [class*="col-lg-"] { + padding: 8% 5%; + padding-top: 8vw; // A flex item cannot have % padding top and bottom (even if it works on chrome) + padding-bottom: 8vw; // Solution is vw units but we keep 8% as a fallback + } + @include media-breakpoint-down(md) { + [class*="col-lg-"] { + flex: 1 1 100%; + } + } + + img { + max-width: 100%; + height: auto; + } +} diff --git a/addons/website/static/src/snippets/s_company_team/000.scss b/addons/website/static/src/snippets/s_company_team/000.scss new file mode 100644 index 00000000..7947b831 --- /dev/null +++ b/addons/website/static/src/snippets/s_company_team/000.scss @@ -0,0 +1,8 @@ + +.s_company_team { + @include media-breakpoint-down(md) { + img { + max-width: 50%; + } + } +} diff --git a/addons/website/static/src/snippets/s_comparisons/000.scss b/addons/website/static/src/snippets/s_comparisons/000.scss new file mode 100644 index 00000000..cf1e9218 --- /dev/null +++ b/addons/website/static/src/snippets/s_comparisons/000.scss @@ -0,0 +1,21 @@ + +.s_comparisons { + .card-body { + .card-title { + margin: 0; + } + .s_comparisons_currency, + .s_comparisons_price, + .s_comparisons_decimal { + display: inline-block; + vertical-align: middle; + } + .s_comparisons_currency, + .s_comparisons_decimal { + font-size: 80%; + } + .s_comparisons_price { + font-size: 200%; + } + } +} diff --git a/addons/website/static/src/snippets/s_countdown/000.js b/addons/website/static/src/snippets/s_countdown/000.js new file mode 100644 index 00000000..fcdac7b7 --- /dev/null +++ b/addons/website/static/src/snippets/s_countdown/000.js @@ -0,0 +1,422 @@ +odoo.define('website.s_countdown', function (require) { +'use strict'; + +const {ColorpickerWidget} = require('web.Colorpicker'); +const core = require('web.core'); +const publicWidget = require('web.public.widget'); +const weUtils = require('web_editor.utils'); + +const qweb = core.qweb; +const _t = core._t; + +const CountdownWidget = publicWidget.Widget.extend({ + selector: '.s_countdown', + xmlDependencies: ['/website/static/src/snippets/s_countdown/000.xml'], + disabledInEditableMode: false, + defaultColor: 'rgba(0, 0, 0, 255)', + + /** + * @override + */ + start: function () { + this.$wrapper = this.$('.s_countdown_canvas_wrapper'); + this.hereBeforeTimerEnds = false; + this.endAction = this.el.dataset.endAction; + this.endTime = parseInt(this.el.dataset.endTime); + this.size = parseInt(this.el.dataset.size); + this.display = this.el.dataset.display; + + this.layout = this.el.dataset.layout; + this.layoutBackground = this.el.dataset.layoutBackground; + this.progressBarStyle = this.el.dataset.progressBarStyle; + this.progressBarWeight = this.el.dataset.progressBarWeight; + + this.textColor = this._ensureCssColor(this.el.dataset.textColor); + this.layoutBackgroundColor = this._ensureCssColor(this.el.dataset.layoutBackgroundColor); + this.progressBarColor = this._ensureCssColor(this.el.dataset.progressBarColor); + + this.onlyOneUnit = this.display === 'd'; + this.width = parseInt(this.size); + if (this.layout === 'boxes') { + this.width /= 1.75; + } + this._initTimeDiff(); + + this._render(); + + this.setInterval = setInterval(this._render.bind(this), 1000); + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this.$('.s_countdown_end_redirect_message').remove(); + this.$('canvas').remove(); + this.$('.s_countdown_end_message').addClass('d-none'); + this.$('.s_countdown_text_wrapper').remove(); + this.$('.s_countdown_canvas_wrapper').removeClass('d-none'); + + clearInterval(this.setInterval); + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Ensures the color is an actual css color. In case of a color variable, + * the color will be mapped to hexa. + * + * @private + * @param {string} color + * @returns {string} + */ + _ensureCssColor: function (color) { + if (ColorpickerWidget.isCSSColor(color)) { + return color; + } + return weUtils.getCSSVariableValue(color) || this.defaultColor; + }, + /** + * Gets the time difference in seconds between now and countdown due date. + * + * @private + */ + _getDelta: function () { + const currentTimestamp = Date.now() / 1000; + return this.endTime - currentTimestamp; + }, + /** + * Handles the action that should be executed once the countdown ends. + * + * @private + */ + _handleEndCountdownAction: function () { + if (this.endAction === 'redirect') { + const redirectUrl = this.el.dataset.redirectUrl || '/'; + if (this.hereBeforeTimerEnds) { + // Wait a bit, if the landing page has the same publish date + setTimeout(() => window.location = redirectUrl, 500); + } else { + // Show (non editable) msg when user lands on already finished countdown + if (!this.$('.s_countdown_end_redirect_message').length) { + const $container = this.$('> .container, > .container-fluid, > .o_container_small'); + $container.append( + $(qweb.render('website.s_countdown.end_redirect_message', { + redirectUrl: redirectUrl, + })) + ); + } + } + } else if (this.endAction === 'message') { + this.$('.s_countdown_end_message').removeClass('d-none'); + } + }, + /** + * Initializes the `diff` object. It will contains every visible time unit + * which will each contain its related canvas, total step, label.. + * + * @private + */ + _initTimeDiff: function () { + const delta = this._getDelta(); + this.diff = []; + if (this._isUnitVisible('d') && !(this.onlyOneUnit && delta < 86400)) { + this.diff.push({ + canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0], + // There is no logical number of unit (total) on which day units + // can be compared against, so we use an arbitrary number. + total: 15, + label: _t("Days"), + nbSeconds: 86400, + }); + } + if (this._isUnitVisible('h') || (this.onlyOneUnit && delta < 86400 && delta > 3600)) { + this.diff.push({ + canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0], + total: 24, + label: _t("Hours"), + nbSeconds: 3600, + }); + } + if (this._isUnitVisible('m') || (this.onlyOneUnit && delta < 3600 && delta > 60)) { + this.diff.push({ + canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0], + total: 60, + label: _t("Minutes"), + nbSeconds: 60, + }); + } + if (this._isUnitVisible('s') || (this.onlyOneUnit && delta < 60)) { + this.diff.push({ + canvas: $('<canvas/>', {class: 'o_temp_auto_element'}).appendTo(this.$wrapper)[0], + total: 60, + label: _t("Seconds"), + nbSeconds: 1, + }); + } + }, + /** + * Returns weither or not the countdown should be displayed for the given + * unit (days, sec..). + * + * @private + * @param {string} unit - either 'd', 'm', 'h', or 's' + * @returns {boolean} + */ + _isUnitVisible: function (unit) { + return this.display.includes(unit); + }, + /** + * Draws the whole countdown, including one countdown for each time unit. + * + * @private + */ + _render: function () { + // If only one unit mode, restart widget on unit change to populate diff + if (this.onlyOneUnit && this._getDelta() < this.diff[0].nbSeconds) { + this.$('canvas').remove(); + this._initTimeDiff(); + } + this._updateTimeDiff(); + + const hideCountdown = this.isFinished && !this.editableMode && this.$el.hasClass('hide-countdown'); + if (this.layout === 'text') { + this.$('canvas').addClass('d-none'); + if (!this.$textWrapper) { + this.$textWrapper = $('<span/>').attr({ + class: 's_countdown_text_wrapper d-none', + }); + this.$textWrapper.text(_t("Countdown ends in")); + this.$textWrapper.append($('<span/>').attr({ + class: 's_countdown_text ml-1', + })); + this.$textWrapper.appendTo(this.$wrapper); + } + + this.$textWrapper.toggleClass('d-none', hideCountdown); + + const countdownText = this.diff.map(e => e.nb + ' ' + e.label).join(', '); + this.$('.s_countdown_text').text(countdownText.toLowerCase()); + } else { + for (const val of this.diff) { + const canvas = val.canvas; + const ctx = canvas.getContext("2d"); + ctx.canvas.width = this.width; + ctx.canvas.height = this.size; + this._clearCanvas(ctx); + + $(canvas).toggleClass('d-none', hideCountdown); + if (hideCountdown) { + continue; + } + + // Draw canvas elements + if (this.layoutBackground !== 'none') { + this._drawBgShape(ctx, this.layoutBackground === 'plain'); + } + this._drawText(canvas, val.nb, val.label, this.layoutBackground === 'plain'); + if (this.progressBarStyle === 'surrounded') { + this._drawProgressBarBg(ctx, this.progressBarWeight === 'thin'); + } + if (this.progressBarStyle !== 'none') { + this._drawProgressBar(ctx, val.nb, val.total, this.progressBarWeight === 'thin'); + } + $(canvas).toggleClass('mx-2', this.layout === 'boxes'); + } + } + + if (this.isFinished) { + clearInterval(this.setInterval); + if (!this.editableMode) { + this._handleEndCountdownAction(); + } + } + }, + /** + * Updates the remaining units into the `diff` object. + * + * @private + */ + _updateTimeDiff: function () { + let delta = this._getDelta(); + this.isFinished = delta < 0; + if (this.isFinished) { + for (const unitData of this.diff) { + unitData.nb = 0; + } + return; + } + + this.hereBeforeTimerEnds = true; + for (const unitData of this.diff) { + unitData.nb = Math.floor(delta / unitData.nbSeconds); + delta -= unitData.nb * unitData.nbSeconds; + } + }, + + //-------------------------------------------------------------------------- + // Canvas drawing methods + //-------------------------------------------------------------------------- + + /** + * Erases the canvas. + * + * @private + * @param {RenderingContext} ctx - Context of the canvas + */ + _clearCanvas: function (ctx) { + ctx.clearRect(0, 0, this.size, this.size); + }, + /** + * Draws a text into the canvas. + * + * @private + * @param {HTMLCanvasElement} canvas + * @param {string} textNb - text to display in the center of the canvas, in big + * @param {string} textUnit - text to display bellow `textNb` in small + * @param {boolean} full - if true, the shape will be drawn up to the progressbar + */ + _drawText: function (canvas, textNb, textUnit, full = false) { + const ctx = canvas.getContext("2d"); + const nbSize = this.size / 4; + ctx.font = `${nbSize}px Arial`; + ctx.fillStyle = this.textColor; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(textNb, canvas.width / 2, canvas.height / 2); + + const unitSize = this.size / 12; + ctx.font = `${unitSize}px Arial`; + ctx.fillText(textUnit, canvas.width / 2, canvas.height / 2 + nbSize / 1.5, this.width); + + if (this.layout === 'boxes' && this.layoutBackground !== 'none' && this.progressBarStyle === 'none') { + let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10); + if (full) { + barWidth = 0; + } + ctx.beginPath(); + ctx.moveTo(barWidth, this.size / 2); + ctx.lineTo(this.width - barWidth, this.size / 2); + ctx.stroke(); + } + }, + /** + * Draws a plain shape into the canvas. + * + * @private + * @param {RenderingContext} ctx - Context of the canvas + * @param {boolean} full - if true, the shape will be drawn up to the progressbar + */ + _drawBgShape: function (ctx, full = false) { + ctx.fillStyle = this.layoutBackgroundColor; + ctx.beginPath(); + if (this.layout === 'circle') { + let rayon = this.size / 2; + if (this.progressBarWeight === 'thin') { + rayon -= full ? this.size / 29 : this.size / 15; + } else { + rayon -= full ? 0 : this.size / 10; + } + ctx.arc(this.size / 2, this.size / 2, rayon, 0, Math.PI * 2); + ctx.fill(); + } else if (this.layout === 'boxes') { + let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10); + if (full) { + barWidth = 0; + } + + ctx.fillStyle = this.layoutBackgroundColor; + ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2); + ctx.fill(); + + const gradient = ctx.createLinearGradient(0, this.width, 0, 0); + gradient.addColorStop(0, '#ffffff24'); + gradient.addColorStop(1, this.layoutBackgroundColor); + ctx.fillStyle = gradient; + ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2); + ctx.fill(); + $(ctx.canvas).css({'border-radius': '8px'}); + } + }, + /** + * Draws a progress bar around the countdown shape. + * + * @private + * @param {RenderingContext} ctx - Context of the canvas + * @param {string} nbUnit - how many unit should fill progress bar + * @param {string} totalUnit - number of unit to do a complete progress bar + * @param {boolean} thinLine - if true, the progress bar will be thiner + */ + _drawProgressBar: function (ctx, nbUnit, totalUnit, thinLine) { + ctx.strokeStyle = this.progressBarColor; + ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10; + if (this.layout === 'circle') { + ctx.beginPath(); + ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, Math.PI / -2, (Math.PI * 2) * (nbUnit / totalUnit) + (Math.PI / -2)); + ctx.stroke(); + } else if (this.layout === 'boxes') { + ctx.lineWidth *= 2; + let pc = nbUnit / totalUnit * 100; + + // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2) + const linesCoordFuncs = [ + (linePc) => [0 + ctx.lineWidth / 2, 0, (this.width - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2, 0], + (linePc) => [this.width, 0 + ctx.lineWidth / 2, this.width, (this.size - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2], + (linePc) => [this.width - ((this.width - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, this.size, this.width - ctx.lineWidth / 2, this.size], + (linePc) => [0, this.size - ((this.size - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, 0, this.size - ctx.lineWidth / 2], + ]; + while (pc > 0 && linesCoordFuncs.length) { + const linePc = Math.min(pc, 25); + const lineCoord = (linesCoordFuncs.shift())(linePc); + ctx.beginPath(); + ctx.moveTo(lineCoord[0], lineCoord[1]); + ctx.lineTo(lineCoord[2], lineCoord[3]); + ctx.stroke(); + pc -= linePc; + } + } + }, + /** + * Draws a full lighter background progressbar around the shape. + * + * @private + * @param {RenderingContext} ctx - Context of the canvas + * @param {boolean} thinLine - if true, the progress bar will be thiner + */ + _drawProgressBarBg: function (ctx, thinLine) { + ctx.strokeStyle = this.progressBarColor; + ctx.globalAlpha = 0.2; + ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10; + if (this.layout === 'circle') { + ctx.beginPath(); + ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, 0, Math.PI * 2); + ctx.stroke(); + } else if (this.layout === 'boxes') { + ctx.lineWidth *= 2; + + // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2) + const points = [ + [0 + ctx.lineWidth / 2, 0, this.width, 0], + [this.width, 0 + ctx.lineWidth / 2, this.width, this.size], + [0, this.size, this.width - ctx.lineWidth / 2, this.size], + [0, 0, 0, this.size - ctx.lineWidth / 2], + ]; + while (points.length) { + const point = points.shift(); + ctx.beginPath(); + ctx.moveTo(point[0], point[1]); + ctx.lineTo(point[2], point[3]); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + }, +}); + +publicWidget.registry.countdown = CountdownWidget; + +return CountdownWidget; +}); diff --git a/addons/website/static/src/snippets/s_countdown/000.xml b/addons/website/static/src/snippets/s_countdown/000.xml new file mode 100644 index 00000000..07905447 --- /dev/null +++ b/addons/website/static/src/snippets/s_countdown/000.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + <t t-name="website.s_countdown.end_redirect_message"> + <p class="text-center s_countdown_end_redirect_message">Time's up! You can now visit <a class="s_countdown_end_redirect_url" t-attf-href="#{redirectUrl}">this page</a>.</p> + </t> + <t t-name="website.s_countdown.end_message"> + <div class="s_countdown_end_message d-none"> + <div class="text-center alert alert-info css_non_editable_mode_hidden o_not_editable" t-ignore="True" role="status"> + The following message will become visible <strong>only</strong> once the countdown ends. + </div> + <div class="oe_structure"> + <section class="s_picture bg-200 pt48 pb24" data-snippet="s_picture"> + <div class="container"> + <h2 style="text-align: center;">Happy Odoo Anniversary!</h2> + <p style="text-align: center;">As promised, we will offer 4 free tickets to our next summit.<br/>Visit our Facebook page to know if you are one of the lucky winners.</p> + <p><br/></p> + <div class="row s_nb_column_fixed"> + <div class="col-lg-12 pb24"> + <figure class="figure"> + <img src="/web/image/website.library_image_18" class="figure-img img-thumbnail mx-auto padding-large" style="width: 50%;" alt="Countdown is over - Firework"/> + </figure> + </div> + </div> + </div> + </section> + </div> + </div> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_countdown/options.js b/addons/website/static/src/snippets/s_countdown/options.js new file mode 100644 index 00000000..ee99e0a8 --- /dev/null +++ b/addons/website/static/src/snippets/s_countdown/options.js @@ -0,0 +1,135 @@ +odoo.define('website.s_countdown_options', function (require) { +'use strict'; + +const core = require('web.core'); +const options = require('web_editor.snippets.options'); +const CountdownWidget = require('website.s_countdown'); + +const qweb = core.qweb; + +options.registry.countdown = options.Class.extend({ + events: _.extend({}, options.Class.prototype.events || {}, { + 'click .toggle-edit-message': '_onToggleEndMessageClick', + }), + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the countdown action at zero. + * + * @see this.selectClass for parameters + */ + endAction: function (previewMode, widgetValue, params) { + this.$target[0].dataset.endAction = widgetValue; + if (widgetValue === 'message') { + if (!this.$target.find('.s_countdown_end_message').length) { + const message = this.endMessage || qweb.render('website.s_countdown.end_message'); + this.$target.append(message); + } + } else { + const $message = this.$target.find('.s_countdown_end_message').detach(); + if ($message.length) { + this.endMessage = $message[0].outerHTML; + } + } + }, + /** + * Changes the countdown style. + * + * @see this.selectClass for parameters + */ + layout: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'circle': + this.$target[0].dataset.progressBarStyle = 'disappear'; + this.$target[0].dataset.progressBarWeight = 'thin'; + this.$target[0].dataset.layoutBackground = 'none'; + break; + case 'boxes': + this.$target[0].dataset.progressBarStyle = 'none'; + this.$target[0].dataset.layoutBackground = 'plain'; + break; + case 'clean': + this.$target[0].dataset.progressBarStyle = 'none'; + this.$target[0].dataset.layoutBackground = 'none'; + break; + case 'text': + this.$target[0].dataset.progressBarStyle = 'none'; + this.$target[0].dataset.layoutBackground = 'none'; + break; + } + this.$target[0].dataset.layout = widgetValue; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + updateUIVisibility: async function () { + await this._super(...arguments); + const dataset = this.$target[0].dataset; + + // End Action UI + this.$el.find('.toggle-edit-message') + .toggleClass('d-none', dataset.endAction !== 'message'); + + // End Message UI + this.updateUIEndMessage(); + }, + /** + * @see this.updateUI + */ + updateUIEndMessage: function () { + this.$target.find('.s_countdown_canvas_wrapper') + .toggleClass("d-none", this.showEndMessage === true && this.$target.hasClass("hide-countdown")); + this.$target.find('.s_countdown_end_message') + .toggleClass("d-none", !this.showEndMessage); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'endAction': + case 'layout': + return this.$target[0].dataset[methodName]; + + case 'selectDataAttribute': { + if (params.colorNames) { + // In this case, it is a colorpicker controlling a data + // value on the countdown: the default value is determined + // by the countdown public widget. + params.attributeDefaultValue = CountdownWidget.prototype.defaultColor; + } + break; + } + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onToggleEndMessageClick: function () { + this.showEndMessage = !this.showEndMessage; + this.$el.find(".toggle-edit-message") + .toggleClass('text-primary', this.showEndMessage); + this.updateUIEndMessage(); + this.trigger_up('cover_update'); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.js b/addons/website/static/src/snippets/s_dynamic_snippet/000.js new file mode 100644 index 00000000..d6a3e0ff --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.js @@ -0,0 +1,244 @@ +odoo.define('website.s_dynamic_snippet', function (require) { +'use strict'; + +const core = require('web.core'); +const config = require('web.config'); +const publicWidget = require('web.public.widget'); + +const DynamicSnippet = publicWidget.Widget.extend({ + selector: '.s_dynamic_snippet', + xmlDependencies: ['/website/static/src/snippets/s_dynamic_snippet/000.xml'], + read_events: { + 'click [data-url]': '_onCallToAction', + }, + disabledInEditableMode: false, + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + /** + * The dynamic filter data source data formatted with the chosen template. + * Can be accessed when overriding the _render_content() function in order to generate + * a new renderedContent from the original data. + * + * @type {*|jQuery.fn.init|jQuery|HTMLElement} + */ + this.data = []; + this.renderedContent = ''; + this.isDesplayedAsMobile = config.device.isMobile; + this.uniqueId = _.uniqueId('s_dynamic_snippet_'); + this.template_key = 'website.s_dynamic_snippet.grid'; + }, + /** + * + * @override + */ + willStart: function () { + return this._super.apply(this, arguments).then( + () => Promise.all([ + this._fetchData(), + this._manageWarningMessageVisibility() + ]) + ); + }, + /** + * + * @override + */ + start: function () { + return this._super.apply(this, arguments) + .then(() => { + this._setupSizeChangedManagement(true); + this._render(); + this._toggleVisibility(true); + }); + }, + /** + * + * @override + */ + destroy: function () { + this._toggleVisibility(false); + this._setupSizeChangedManagement(false); + this._clearContent(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @private + */ + _clearContent: function () { + const $dynamicSnippetTemplate = this.$el.find('.dynamic_snippet_template'); + if ($dynamicSnippetTemplate) { + $dynamicSnippetTemplate.html(''); + } + }, + /** + * Method to be overridden in child components if additional configuration elements + * are required in order to fetch data. + * @private + */ + _isConfigComplete: function () { + return this.$el.get(0).dataset.filterId !== undefined && this.$el.get(0).dataset.templateKey !== undefined; + }, + /** + * Method to be overridden in child components in order to provide a search + * domain if needed. + * @private + */ + _getSearchDomain: function () { + return []; + }, + /** + * Fetches the data. + * @private + */ + _fetchData: function () { + if (this._isConfigComplete()) { + return this._rpc( + { + 'route': '/website/snippet/filters', + 'params': { + 'filter_id': parseInt(this.$el.get(0).dataset.filterId), + 'template_key': this.$el.get(0).dataset.templateKey, + 'limit': parseInt(this.$el.get(0).dataset.numberOfRecords), + 'search_domain': this._getSearchDomain() + }, + }) + .then( + (data) => { + this.data = data; + } + ); + } else { + return new Promise((resolve) => { + this.data = []; + resolve(); + }); + } + }, + /** + * + * @private + */ + _mustMessageWarningBeHidden: function() { + return this._isConfigComplete() || !this.editableMode; + }, + /** + * + * @private + */ + _manageWarningMessageVisibility: async function () { + this.$el.find('.missing_option_warning').toggleClass( + 'd-none', + this._mustMessageWarningBeHidden() + ); + }, + /** + * Method to be overridden in child components in order to prepare content + * before rendering. + * @private + */ + _prepareContent: function () { + if (this.$target[0].dataset.numberOfElements && this.$target[0].dataset.numberOfElementsSmallDevices) { + this.renderedContent = core.qweb.render( + this.template_key, + this._getQWebRenderOptions()); + } else { + this.renderedContent = ''; + } + }, + /** + * Method to be overridden in child components in order to prepare QWeb + * options. + * @private + */ + _getQWebRenderOptions: function () { + return { + chunkSize: parseInt( + config.device.isMobile + ? this.$target[0].dataset.numberOfElementsSmallDevices + : this.$target[0].dataset.numberOfElements + ), + data: this.data, + uniqueId: this.uniqueId + }; + }, + /** + * + * @private + */ + _render: function () { + if (this.data.length) { + this._prepareContent(); + } else { + this.renderedContent = ''; + } + this._renderContent(); + }, + /** + * + * @private + */ + _renderContent: function () { + this.$el.find('.dynamic_snippet_template').html(this.renderedContent); + }, + /** + * + * @param {Boolean} enable + * @private + */ + _setupSizeChangedManagement: function (enable) { + if (enable === true) { + config.device.bus.on('size_changed', this, this._onSizeChanged); + } else { + config.device.bus.off('size_changed', this, this._onSizeChanged); + } + }, + /** + * + * @param visible + * @private + */ + _toggleVisibility: function (visible) { + this.$el.toggleClass('d-none', !visible); + }, + + //------------------------------------- ------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Navigates to the call to action url. + * @private + */ + _onCallToAction: function (ev) { + window.location = $(ev.currentTarget).attr('data-url'); + }, + /** + * Called when the size has reached a new bootstrap breakpoint. + * + * @private + * @param {number} size as Integer @see web.config.device.SIZES + */ + _onSizeChanged: function (size) { + if (this.isDesplayedAsMobile !== config.device.isMobile) { + this.isDesplayedAsMobile = config.device.isMobile; + this._render(); + } + }, +}); + +publicWidget.registry.dynamic_snippet = DynamicSnippet; + +return DynamicSnippet; + +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss new file mode 100644 index 00000000..536aba85 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.scss @@ -0,0 +1,11 @@ +.s_dynamic { + [data-url] { + cursor: pointer; + } + .card-img-top { + height: 12rem; + } + img { + object-fit: scale-down; + } +} diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml new file mode 100644 index 00000000..105078e0 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="website.s_dynamic_snippet.grid"> + <!-- Content --> + <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/> + <t t-set="rowIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/> + <t t-set="colIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/> + <t t-foreach="rowIndexGenerator" t-as="rowIndex"> + <div class="row my-4"> + <t t-foreach="colIndexGenerator" t-as="colIndex"> + <t t-if="(rowIndex * chunkSize + colIndex) < data.length"> + <div t-attf-class="#{colClass}"> + <t t-raw="data[rowIndex * chunkSize + colIndex]"/> + </div> + </t> + </t> + </div> + </t> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.js b/addons/website/static/src/snippets/s_dynamic_snippet/options.js new file mode 100644 index 00000000..2cbdcb1c --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.js @@ -0,0 +1,136 @@ +odoo.define('website.s_dynamic_snippet_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +const dynamicSnippetOptions = options.Class.extend({ + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.dynamicFilters = {}; + this.dynamicFilterTemplates = {}; + }, + /** + * + * @override + */ + onBuilt: function () { + this._setOptionsDefaultValues(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * + * @see this.selectClass for parameters + */ + selectDataAttribute: function (previewMode, widgetValue, params) { + this._super.apply(this, arguments); + if (params.attributeName === 'filterId' && previewMode === false) { + this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[parseInt(widgetValue)].limit; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetches dynamic filters. + * @private + * @returns {Promise} + */ + _fetchDynamicFilters: function () { + return this._rpc({route: '/website/snippet/options_filters'}); + }, + /** + * Fetch dynamic filters templates. + * @private + * @returns {Promise} + */ + _fetchDynamicFilterTemplates: function () { + return this._rpc({route: '/website/snippet/filter_templates'}); + }, + /** + * + * @override + * @private + */ + _renderCustomXML: async function (uiFragment) { + await this._renderDynamicFiltersSelector(uiFragment); + await this._renderDynamicFilterTemplatesSelector(uiFragment); + }, + /** + * Renders the dynamic filter option selector content into the provided uiFragment. + * @param {HTMLElement} uiFragment + * @private + */ + _renderDynamicFiltersSelector: async function (uiFragment) { + const dynamicFilters = await this._fetchDynamicFilters(); + for (let index in dynamicFilters) { + this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index]; + } + const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]'); + return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters); + }, + /** + * Renders we-buttons into a SelectUserValueWidget element according to provided data. + * @param {HTMLElement} selectUserValueWidgetElement the SelectUserValueWidget buttons + * have to be created into. + * @param {JSON} data + * @private + */ + _renderSelectUserValueWidgetButtons: async function (selectUserValueWidgetElement, data) { + for (let id in data) { + const button = document.createElement('we-button'); + button.dataset.selectDataAttribute = id; + button.innerHTML = data[id].name; + selectUserValueWidgetElement.appendChild(button); + } + }, + /** + * Renders the template option selector content into the provided uiFragment. + * @param {HTMLElement} uiFragment + * @private + */ + _renderDynamicFilterTemplatesSelector: async function (uiFragment) { + const dynamicFilterTemplates = await this._fetchDynamicFilterTemplates(); + for (let index in dynamicFilterTemplates) { + this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index]; + } + const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]'); + return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates); + }, + /** + * Sets default options values. + * Method to be overridden in child components in order to set additional + * options default values. + * @private + */ + _setOptionsDefaultValues: function () { + this._setOptionValue('numberOfElements', 4); + this._setOptionValue('numberOfElementsSmallDevices', 1); + }, + /** + * Sets the option value. + * @param optionName + * @param value + * @private + */ + _setOptionValue: function (optionName, value) { + if (this.$target.get(0).dataset[optionName] === undefined) { + this.$target.get(0).dataset[optionName] = value; + } + }, +}); + +options.registry.dynamic_snippet = dynamicSnippetOptions; + +return dynamicSnippetOptions; +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js new file mode 100644 index 00000000..cf43100a --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js @@ -0,0 +1,46 @@ +odoo.define('website.s_dynamic_snippet_carousel', function (require) { +'use strict'; + +const config = require('web.config'); +const core = require('web.core'); +const publicWidget = require('web.public.widget'); +const DynamicSnippet = require('website.s_dynamic_snippet'); + +const DynamicSnippetCarousel = DynamicSnippet.extend({ + selector: '.s_dynamic_snippet_carousel', + xmlDependencies: (DynamicSnippet.prototype.xmlDependencies || []).concat( + ['/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml'] + ), + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.template_key = 'website.s_dynamic_snippet.carousel'; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Method to be overridden in child components in order to prepare QWeb + * options + * @private + */ + _getQWebRenderParams: function () { + return Object.assign( + this._super.apply(this, arguments), + { + interval : parseInt(this.$target[0].dataset.carouselInterval), + }, + ); + }, + +}); +publicWidget.registry.dynamic_snippet_carousel = DynamicSnippetCarousel; + +return DynamicSnippetCarousel; +}); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss new file mode 100644 index 00000000..3eceb172 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss @@ -0,0 +1,11 @@ +.s_dynamic { + .carousel-control-prev, .carousel-control-next { + position: absolute; + width: 4rem; + + > span.fa { + color: gray('700'); + background: radial-gradient($white 50%, transparent 50%); + } + } +} diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml new file mode 100644 index 00000000..1efb8f22 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="website.s_dynamic_snippet.carousel"> + <div t-att-id="uniqueId" class="carousel slide" t-att-data-interval="interval"> + <!-- Content --> + <div class="carousel-inner row w-100 mx-auto" role="listbox"> + <t t-set="colClass" t-value="'col-' + (12 / chunkSize).toString()"/> + <t t-set="slideIndexGenerator" t-value="Array.from(Array(Math.ceil(data.length/chunkSize)).keys())"/> + <t t-set="itemIndexGenerator" t-value="Array.from(Array(chunkSize).keys())"/> + <t t-foreach="slideIndexGenerator" t-as="slideIndex"> + <div t-attf-class="carousel-item #{slideIndex_first ? 'active' : ''}"> + <div class="row"> + <t t-foreach="itemIndexGenerator" t-as="itemIndex"> + <t t-if="(slideIndex * chunkSize + itemIndex) < data.length"> + <div t-attf-class="#{colClass}"> + <t t-raw="data[slideIndex * chunkSize + itemIndex]"/> + </div> + </t> + </t> + </div> + </div> + </t> + </div> + <!-- Controls --> + <a t-attf-href="##{uniqueId}" class="carousel-control-prev" data-slide="prev" role="button" aria-label="Previous" title="Previous"> + <span class="fa fa-chevron-circle-left fa-2x"/> + <span class="sr-only">Previous</span> + </a> + <a t-attf-href="##{uniqueId}" class="carousel-control-next" data-slide="next" role="button" aria-label="Next" title="Next"> + <span class="fa fa-chevron-circle-right fa-2x"/> + <span class="sr-only">Next</span> + </a> + </div> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js new file mode 100644 index 00000000..cb9a3cf5 --- /dev/null +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/options.js @@ -0,0 +1,28 @@ +odoo.define('website.s_dynamic_snippet_carousel_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); +const s_dynamic_snippet_options = require('website.s_dynamic_snippet_options'); + +const dynamicSnippetCarouselOptions = s_dynamic_snippet_options.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _setOptionsDefaultValues: function () { + this._super.apply(this, arguments); + this._setOptionValue('carouselInterval', '5000'); + } + +}); + +options.registry.dynamic_snippet_carousel = dynamicSnippetCarouselOptions; + +return dynamicSnippetCarouselOptions; +}); diff --git a/addons/website/static/src/snippets/s_facebook_page/000.js b/addons/website/static/src/snippets/s_facebook_page/000.js new file mode 100644 index 00000000..0a88f1b3 --- /dev/null +++ b/addons/website/static/src/snippets/s_facebook_page/000.js @@ -0,0 +1,56 @@ +odoo.define('website.s_facebook_page', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var utils = require('web.utils'); + +const FacebookPageWidget = publicWidget.Widget.extend({ + selector: '.o_facebook_page', + disabledInEditableMode: false, + + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + + var params = _.pick(this.$el.data(), 'href', 'height', 'tabs', 'small_header', 'hide_cover', 'show_facepile'); + if (!params.href) { + return def; + } + params.width = utils.confine(Math.floor(this.$el.width()), 180, 500); + + var src = $.param.querystring('https://www.facebook.com/plugins/page.php', params); + this.$iframe = $('<iframe/>', { + src: src, + class: 'o_temp_auto_element', + width: params.width, + height: params.height, + css: { + border: 'none', + overflow: 'hidden', + }, + scrolling: 'no', + frameborder: '0', + allowTransparency: 'true', + }); + this.$el.append(this.$iframe); + + return def; + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + + if (this.$iframe) { + this.$iframe.remove(); + } + }, +}); + +publicWidget.registry.facebookPage = FacebookPageWidget; + +return FacebookPageWidget; +}); diff --git a/addons/website/static/src/snippets/s_facebook_page/options.js b/addons/website/static/src/snippets/s_facebook_page/options.js new file mode 100644 index 00000000..da6c94dc --- /dev/null +++ b/addons/website/static/src/snippets/s_facebook_page/options.js @@ -0,0 +1,157 @@ +odoo.define('website.s_facebook_page_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.facebookPage = options.Class.extend({ + /** + * Initializes the required facebook page data to create the iframe. + * + * @override + */ + willStart: function () { + var defs = [this._super.apply(this, arguments)]; + + var defaults = { + href: '', + height: 215, + width: 350, + tabs: '', + small_header: true, + hide_cover: true, + show_facepile: false, + }; + this.fbData = _.defaults(_.pick(this.$target.data(), _.keys(defaults)), defaults); + + if (!this.fbData.href) { + // Fetches the default url for facebook page from website config + var self = this; + defs.push(this._rpc({ + model: 'website', + method: 'search_read', + args: [[], ['social_facebook']], + limit: 1, + }).then(function (res) { + if (res) { + self.fbData.href = res[0].social_facebook || ''; + } + })); + } + + return Promise.all(defs).then(() => this._markFbElement()).then(() => this._refreshPublicWidgets()); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles a checkbox option. + * + * @see this.selectClass for parameters + * @param {String} optionName the name of the option to toggle + */ + toggleOption: function (previewMode, widgetValue, params) { + let optionName = params.optionName; + if (optionName.startsWith('tab.')) { + optionName = optionName.replace('tab.', ''); + if (widgetValue) { + this.fbData.tabs = this.fbData.tabs + .split(',') + .filter(t => t !== '') + .concat([optionName]) + .join(','); + } else { + this.fbData.tabs = this.fbData.tabs + .split(',') + .filter(t => t !== optionName) + .join(','); + } + } else { + if (optionName === 'show_cover') { + this.fbData.hide_cover = !widgetValue; + } else { + this.fbData[optionName] = widgetValue; + } + } + return this._markFbElement(); + }, + /** + * Sets the facebook page's URL. + * + * @see this.selectClass for parameters + */ + pageUrl: function (previewMode, widgetValue, params) { + this.fbData.href = widgetValue; + return this._markFbElement(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Sets the correct dataAttributes on the facebook iframe and refreshes it. + * + * @see this.selectClass for parameters + */ + _markFbElement: function () { + return this._checkURL().then(() => { + // Managing height based on options + if (this.fbData.tabs) { + this.fbData.height = this.fbData.tabs === 'events' ? 300 : 500; + } else if (this.fbData.small_header) { + this.fbData.height = this.fbData.show_facepile ? 165 : 70; + } else { + this.fbData.height = this.fbData.show_facepile ? 225 : 150; + } + _.each(this.fbData, (value, key) => { + this.$target.attr('data-' + key, value); + this.$target.data(key, value); + }); + }); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + const optionName = params.optionName; + switch (methodName) { + case 'toggleOption': { + if (optionName.startsWith('tab.')) { + return this.fbData.tabs.split(',').includes(optionName.replace(/^tab./, '')); + } else { + if (optionName === 'show_cover') { + return !this.fbData.hide_cover; + } + return this.fbData[optionName]; + } + } + case 'pageUrl': { + return this._checkURL().then(() => this.fbData.href); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _checkURL: function () { + const defaultURL = 'https://www.facebook.com/Odoo'; + const match = this.fbData.href.match(/^(?:https?:\/\/)?(?:www\.)?(?:fb|facebook)\.com\/(?:([\w.]+)|[^/?#]+-([0-9]{15,16}))(?:$|[/?# ])/); + if (match) { + // Check if the page exists on Facebook or not + return new Promise((resolve, reject) => $.ajax({ + url: 'https://graph.facebook.com/' + (match[2] || match[1]) + '/picture', + success: () => resolve(), + error: () => { + this.fbData.href = defaultURL; + resolve(); + }, + })); + } + this.fbData.href = defaultURL; + return Promise.resolve(); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_faq_collapse/000.scss b/addons/website/static/src/snippets/s_faq_collapse/000.scss new file mode 100644 index 00000000..af236dea --- /dev/null +++ b/addons/website/static/src/snippets/s_faq_collapse/000.scss @@ -0,0 +1,35 @@ + +.s_faq_collapse { + .accordion .card { + .card-header { + cursor: pointer; + display: inline-block; + width: 100%; + padding: .5em 0; + border-radius: 0; + outline: none; + &:before { + content:'\f056'; + font-family: 'FontAwesome'; + display: inline-block; + margin: 0 .5em 0 .75em; + color: $gray-600; + } + &.collapsed:before { + content:'\f055'; + font-family: 'FontAwesome'; + } + &:hover, + &:focus { + text-decoration: none; + } + } + .card-body { + padding: 1em 2.25em; + } + } + .card-body p:last-child, + .card-body ul:last-child { + margin-bottom: 0; + } +} diff --git a/addons/website/static/src/snippets/s_features_grid/000.scss b/addons/website/static/src/snippets/s_features_grid/000.scss new file mode 100644 index 00000000..d66d72c5 --- /dev/null +++ b/addons/website/static/src/snippets/s_features_grid/000.scss @@ -0,0 +1,13 @@ + +.s_features_grid { + &_content { + overflow: hidden; + p { + margin-bottom: 0; + } + } + &_icon { + float: left; + margin-right: $grid-gutter-width/2; + } +} diff --git a/addons/website/static/src/snippets/s_google_map/000.js b/addons/website/static/src/snippets/s_google_map/000.js new file mode 100644 index 00000000..1fa5d3d7 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/000.js @@ -0,0 +1,96 @@ +odoo.define('website.s_google_map', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +publicWidget.registry.GoogleMap = publicWidget.Widget.extend({ + selector: '.s_google_map', + disabledInEditableMode: false, + + mapColors: { + lightMonoMap: [{"featureType": "administrative.locality", "elementType": "all", "stylers": [{"hue": "#2c2e33"}, {"saturation": 7}, {"lightness": 19}, {"visibility": "on"}]}, {"featureType": "landscape", "elementType": "all", "stylers": [{"hue": "#ffffff"}, {"saturation": -100}, {"lightness": 100}, {"visibility": "simplified"}]}, {"featureType": "poi", "elementType": "all", "stylers": [{"hue": "#ffffff"}, {"saturation": -100}, {"lightness": 100}, {"visibility": "off"}]}, {"featureType": "road", "elementType": "geometry", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": 31}, {"visibility": "simplified"}]}, {"featureType": "road", "elementType": "labels", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": 31}, {"visibility": "on"}]}, {"featureType": "road.arterial", "elementType": "labels", "stylers": [{"hue": "#bbc0c4"}, {"saturation": -93}, {"lightness": -2}, {"visibility": "simplified"}]}, {"featureType": "road.local", "elementType": "geometry", "stylers": [{"hue": "#e9ebed"}, {"saturation": -90}, {"lightness": -8}, {"visibility": "simplified"}]}, {"featureType": "transit", "elementType": "all", "stylers": [{"hue": "#e9ebed"}, {"saturation": 10}, {"lightness": 69}, {"visibility": "on"}]}, {"featureType": "water", "elementType": "all", "stylers": [{"hue": "#e9ebed"}, {"saturation": -78}, {"lightness": 67}, {"visibility": "simplified"}]}], + lillaMap: [{elementType: "labels", stylers: [{saturation: -20}]}, {featureType: "poi", elementType: "labels", stylers: [{visibility: "off"}]}, {featureType: 'road.highway', elementType: 'labels', stylers: [{visibility: "off"}]}, {featureType: "road.local", elementType: "labels.icon", stylers: [{visibility: "off"}]}, {featureType: "road.arterial", elementType: "labels.icon", stylers: [{visibility: "off"}]}, {featureType: "road", elementType: "geometry.stroke", stylers: [{visibility: "off"}]}, {featureType: "transit", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.government", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.sport_complex", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.attraction", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "poi.business", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "transit", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "transit.station", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "landscape", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "road", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "road.highway", elementType: "geometry.fill", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}, {featureType: "water", elementType: "geometry", stylers: [{hue: '#2d313f'}, {visibility: "on"}, {lightness: 5}, {saturation: -20}]}], + blueMap: [{stylers: [{hue: "#00ffe6"}, {saturation: -20}]}, {featureType: "road", elementType: "geometry", stylers: [{lightness: 100}, {visibility: "simplified"}]}, {featureType: "road", elementType: "labels", stylers: [{visibility: "off"}]}], + retroMap: [{"featureType": "administrative", "elementType": "all", "stylers": [{"visibility": "on"}, {"lightness": 33}]}, {"featureType": "landscape", "elementType": "all", "stylers": [{"color": "#f2e5d4"}]}, {"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#c5dac6"}]}, {"featureType": "poi.park", "elementType": "labels", "stylers": [{"visibility": "on"}, {"lightness": 20}]}, {"featureType": "road", "elementType": "all", "stylers": [{"lightness": 20}]}, {"featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#c5c6c6"}]}, {"featureType": "road.arterial", "elementType": "geometry", "stylers": [{"color": "#e4d7c6"}]}, {"featureType": "road.local", "elementType": "geometry", "stylers": [{"color": "#fbfaf7"}]}, {"featureType": "water", "elementType": "all", "stylers": [{"visibility": "on"}, {"color": "#acbcc9"}]}], + flatMap: [{"stylers": [{"visibility": "off"}]}, {"featureType": "road", "stylers": [{"visibility": "on"}, {"color": "#ffffff"}]}, {"featureType": "road.arterial", "stylers": [{"visibility": "on"}, {"color": "#fee379"}]}, {"featureType": "road.highway", "stylers": [{"visibility": "on"}, {"color": "#fee379"}]}, {"featureType": "landscape", "stylers": [{"visibility": "on"}, {"color": "#f3f4f4"}]}, {"featureType": "water", "stylers": [{"visibility": "on"}, {"color": "#7fc8ed"}]}, {}, {"featureType": "road", "elementType": "labels", "stylers": [{"visibility": "on"}]}, {"featureType": "poi.park", "elementType": "geometry.fill", "stylers": [{"visibility": "on"}, {"color": "#83cead"}]}, {"elementType": "labels", "stylers": [{"visibility": "on"}]}, {"featureType": "landscape.man_made", "elementType": "geometry", "stylers": [{"weight": 0.9}, {"visibility": "off"}]}], + cobaltMap: [{"featureType": "all", "elementType": "all", "stylers": [{"invert_lightness": true}, {"saturation": 10}, {"lightness": 30}, {"gamma": 0.5}, {"hue": "#435158"}]}], + cupertinoMap: [{"featureType": "water", "elementType": "geometry", "stylers": [{"color": "#a2daf2"}]}, {"featureType": "landscape.man_made", "elementType": "geometry", "stylers": [{"color": "#f7f1df"}]}, {"featureType": "landscape.natural", "elementType": "geometry", "stylers": [{"color": "#d0e3b4"}]}, {"featureType": "landscape.natural.terrain", "elementType": "geometry", "stylers": [{"visibility": "off"}]}, {"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#bde6ab"}]}, {"featureType": "poi", "elementType": "labels", "stylers": [{"visibility": "off"}]}, {"featureType": "poi.medical", "elementType": "geometry", "stylers": [{"color": "#fbd3da"}]}, {"featureType": "poi.business", "stylers": [{"visibility": "off"}]}, {"featureType": "road", "elementType": "geometry.stroke", "stylers": [{"visibility": "off"}]}, {"featureType": "road", "elementType": "labels", "stylers": [{"visibility": "off"}]}, {"featureType": "road.highway", "elementType": "geometry.fill", "stylers": [{"color": "#ffe15f"}]}, {"featureType": "road.highway", "elementType": "geometry.stroke", "stylers": [{"color": "#efd151"}]}, {"featureType": "road.arterial", "elementType": "geometry.fill", "stylers": [{"color": "#ffffff"}]}, {"featureType": "road.local", "elementType": "geometry.fill", "stylers": [{"color": "black"}]}, {"featureType": "transit.station.airport", "elementType": "geometry.fill", "stylers": [{"color": "#cfb2db"}]}], + carMap: [{"featureType": "administrative", "stylers": [{"visibility": "off"}]}, {"featureType": "poi", "stylers": [{"visibility": "simplified"}]}, {"featureType": "road", "stylers": [{"visibility": "simplified"}]}, {"featureType": "water", "stylers": [{"visibility": "simplified"}]}, {"featureType": "transit", "stylers": [{"visibility": "simplified"}]}, {"featureType": "landscape", "stylers": [{"visibility": "simplified"}]}, {"featureType": "road.highway", "stylers": [{"visibility": "off"}]}, {"featureType": "road.local", "stylers": [{"visibility": "on"}]}, {"featureType": "road.highway", "elementType": "geometry", "stylers": [{"visibility": "on"}]}, {"featureType": "water", "stylers": [{"color": "#84afa3"}, {"lightness": 52}]}, {"stylers": [{"saturation": -77}]}, {"featureType": "road"}], + bwMap: [{stylers: [{hue: "#00ffe6"}, {saturation: -100}]}, {featureType: "road", elementType: "geometry", stylers: [{lightness: 100}, {visibility: "simplified"}]}, {featureType: "road", elementType: "labels", stylers: [{visibility: "off"}]}], + }, + + /** + * @override + */ + async start() { + await this._super(...arguments); + + if (typeof google !== 'object' || typeof google.maps !== 'object') { + await new Promise(resolve => { + this.trigger_up('gmap_api_request', { + editableMode: this.editableMode, + onSuccess: () => resolve(), + }); + }); + // The animation will be restarted for all maps as soon as the + // google map script has been executed. + return; + } + + // Define a default map's colors set + const std = []; + new google.maps.StyledMapType(std, {name: "Std Map"}); + + // Default options, will be overwritten by the user + const myOptions = { + zoom: 12, + center: new google.maps.LatLng(50.854975, 4.3753899), + mapTypeId: google.maps.MapTypeId.ROADMAP, + panControl: false, + zoomControl: false, + mapTypeControl: false, + streetViewControl: false, + scrollwheel: false, + mapTypeControlOptions: { + mapTypeIds: [google.maps.MapTypeId.ROADMAP, 'map_style'] + } + }; + + // Render Map + const mapC = this.$('.map_container'); + const map = new google.maps.Map(mapC.get(0), myOptions); + + // Update GPS position + const p = this.el.dataset.mapGps.substring(1).slice(0, -1).split(','); + const gps = new google.maps.LatLng(p[0], p[1]); + map.setCenter(gps); + + // Update Map on screen resize + google.maps.event.addDomListener(window, 'resize', () => { + map.setCenter(gps); + }); + + // Create Marker & Infowindow + const markerOptions = { + map: map, + animation: google.maps.Animation.DROP, + position: new google.maps.LatLng(p[0], p[1]) + }; + if (this.el.dataset.pinStyle === 'flat') { + markerOptions.icon = '/website/static/src/img/snippets_thumbs/s_google_map_marker.png'; + } + new google.maps.Marker(markerOptions); + + map.setMapTypeId(google.maps.MapTypeId[this.el.dataset.mapType]); // Update Map Type + map.setZoom(parseInt(this.el.dataset.mapZoom)); // Update Map Zoom + + // Update Map Color + const mapColorAttr = this.el.dataset.mapColor; + if (mapColorAttr) { + const mapColor = this.mapColors[mapColorAttr]; + map.mapTypes.set('map_style', new google.maps.StyledMapType(mapColor, {name: "Styled Map"})); + map.setMapTypeId('map_style'); + } + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_google_map/000.scss b/addons/website/static/src/snippets/s_google_map/000.scss new file mode 100644 index 00000000..9a8df732 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/000.scss @@ -0,0 +1,42 @@ + +$s-google-map-desc-bg: theme-color('primary') !default; +$s-google-map-desc-alpha: 0.80 !default; +$s-google-map-desc-hover-bg: theme-color('primary') !default; +$s-google-map-desc-hover-alpha: 0.55 !default; + +.s_google_map { + position: relative; + min-height: 100px; + + .map_container { + @include o-position-absolute(0, 0, 0, 0); + } + .description { + @include o-position-absolute(auto, 0, 0, 0); + z-index: 99; + padding: 0 1em; + background: rgba($s-google-map-desc-bg, $s-google-map-desc-alpha); + color: color-yiq(rgba($s-google-map-desc-bg, $s-google-map-desc-alpha)); + transition: background-color 250ms ease; + + font { + float: left; + margin-top: 20px; + margin-bottom: 15px; + font-weight: bold; + text-transform: uppercase; + } + span { + float: left; + text-transform: none; + font-weight: normal; + margin-top: 20px; + margin-left: 10px; + } + } + &:hover .description { + background: $s-google-map-desc-hover-bg; + background: rgba($s-google-map-desc-hover-bg, $s-google-map-desc-hover-alpha); + color: color-yiq(rgba($s-google-map-desc-hover-bg, $s-google-map-desc-hover-alpha)); + } +} diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg Binary files differnew file mode 100644 index 00000000..a929e345 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-blue.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg Binary files differnew file mode 100644 index 00000000..1dad96b4 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-bw.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg Binary files differnew file mode 100644 index 00000000..55f1046c --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-caramello.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg Binary files differnew file mode 100644 index 00000000..dc0aa590 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cobalt.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg Binary files differnew file mode 100644 index 00000000..06c97ae0 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-cupertino.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg Binary files differnew file mode 100644 index 00000000..ea5c8360 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-default.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg Binary files differnew file mode 100644 index 00000000..9b4df178 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-flat.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg Binary files differnew file mode 100644 index 00000000..17e23249 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lightMono.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg Binary files differnew file mode 100644 index 00000000..62a19e7c --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-lilla.jpg diff --git a/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg Binary files differnew file mode 100644 index 00000000..2c36791b --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/img/thumbs/map-retro.jpg diff --git a/addons/website/static/src/snippets/s_google_map/options.js b/addons/website/static/src/snippets/s_google_map/options.js new file mode 100644 index 00000000..6aad46f1 --- /dev/null +++ b/addons/website/static/src/snippets/s_google_map/options.js @@ -0,0 +1,56 @@ +odoo.define('options.s_google_map_options', function (require) { +'use strict'; + +const {_t} = require('web.core'); +const options = require('web_editor.snippets.options'); + +options.registry.GoogleMap = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + resetMapColor(previewMode, widgetValue, params) { + this.$target[0].dataset.mapColor = ''; + }, + /** + * @see this.selectClass for parameters + */ + setFormattedAddress(previewMode, widgetValue, params) { + this.$target[0].dataset.pinAddress = params.gmapPlace.formatted_address; + }, + /** + * @see this.selectClass for parameters + */ + async showDescription(previewMode, widgetValue, params) { + const descriptionEl = this.$target[0].querySelector('.description'); + if (widgetValue && !descriptionEl) { + this.$target.append($(` + <div class="description"> + <font>${_t('Visit us:')}</font> + <span>${_t('Our office is located in the northeast of Brussels. TEL (555) 432 2365')}</span> + </div>`) + ); + } else if (!widgetValue && descriptionEl) { + descriptionEl.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'showDescription') { + return this.$target[0].querySelector('.description') ? 'true' : ''; + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_hr/000.scss b/addons/website/static/src/snippets/s_hr/000.scss new file mode 100644 index 00000000..410000e2 --- /dev/null +++ b/addons/website/static/src/snippets/s_hr/000.scss @@ -0,0 +1,11 @@ + +.s_hr { + line-height: 0; + hr { + padding: 0; + border: 0; + border-top: 1px solid currentColor; + margin: 0; + color: inherit; + } +} diff --git a/addons/website/static/src/snippets/s_image_gallery/000.js b/addons/website/static/src/snippets/s_image_gallery/000.js new file mode 100644 index 00000000..9bd0f8e3 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/000.js @@ -0,0 +1,180 @@ +odoo.define('website.s_image_gallery', function (require) { +'use strict'; + +var core = require('web.core'); +var publicWidget = require('web.public.widget'); + +var qweb = core.qweb; + +const GalleryWidget = publicWidget.Widget.extend({ + + selector: '.s_image_gallery:not(.o_slideshow)', + xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'], + events: { + 'click img': '_onClickImg', + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when an image is clicked. Opens a dialog to browse all the images + * with a bigger size. + * + * @private + * @param {Event} ev + */ + _onClickImg: function (ev) { + var self = this; + var $cur = $(ev.currentTarget); + + var $images = $cur.closest('.s_image_gallery').find('img'); + var size = 0.8; + var dimensions = { + min_width: Math.round(window.innerWidth * size * 0.9), + min_height: Math.round(window.innerHeight * size), + max_width: Math.round(window.innerWidth * size * 0.9), + max_height: Math.round(window.innerHeight * size), + width: Math.round(window.innerWidth * size * 0.9), + height: Math.round(window.innerHeight * size) + }; + + var $img = ($cur.is('img') === true) ? $cur : $cur.closest('img'); + + const milliseconds = $cur.closest('.s_image_gallery').data('interval') || false; + var $modal = $(qweb.render('website.gallery.slideshow.lightbox', { + images: $images.get(), + index: $images.index($img), + dim: dimensions, + interval: milliseconds || 0, + id: _.uniqueId('slideshow_'), + })); + $modal.modal({ + keyboard: true, + backdrop: true, + }); + $modal.on('hidden.bs.modal', function () { + $(this).hide(); + $(this).siblings().filter('.modal-backdrop').remove(); // bootstrap leaves a modal-backdrop + $(this).remove(); + }); + $modal.find('.modal-content, .modal-body.o_slideshow').css('height', '100%'); + $modal.appendTo(document.body); + + $modal.one('shown.bs.modal', function () { + self.trigger_up('widgets_start_request', { + editableMode: false, + $target: $modal.find('.modal-body.o_slideshow'), + }); + }); + }, +}); + +const GallerySliderWidget = publicWidget.Widget.extend({ + selector: '.o_slideshow', + xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'], + disabledInEditableMode: false, + + /** + * @override + */ + start: function () { + var self = this; + this.$carousel = this.$target.is('.carousel') ? this.$target : this.$target.find('.carousel'); + this.$indicator = this.$carousel.find('.carousel-indicators'); + this.$prev = this.$indicator.find('li.o_indicators_left').css('visibility', ''); // force visibility as some databases have it hidden + this.$next = this.$indicator.find('li.o_indicators_right').css('visibility', ''); + var $lis = this.$indicator.find('li[data-slide-to]'); + let indicatorWidth = this.$indicator.width(); + if (indicatorWidth === 0) { + // An ancestor may be hidden so we try to find it and make it + // visible just to take the correct width. + const $indicatorParent = this.$indicator.parents().not(':visible').last(); + if (!$indicatorParent[0].style.display) { + $indicatorParent[0].style.display = 'block'; + indicatorWidth = this.$indicator.width(); + $indicatorParent[0].style.display = ''; + } + } + let nbPerPage = Math.floor(indicatorWidth / $lis.first().outerWidth(true)) - 3; // - navigator - 1 to leave some space + var realNbPerPage = nbPerPage || 1; + var nbPages = Math.ceil($lis.length / realNbPerPage); + + var index; + var page; + update(); + + function hide() { + $lis.each(function (i) { + $(this).toggleClass('d-none', i < page * nbPerPage || i >= (page + 1) * nbPerPage); + }); + if (page <= 0) { + self.$prev.detach(); + } else { + self.$prev.removeClass('d-none'); + self.$prev.prependTo(self.$indicator); + } + if (page >= nbPages - 1) { + self.$next.detach(); + } else { + self.$next.removeClass('d-none'); + self.$next.appendTo(self.$indicator); + } + } + + function update() { + const active = $lis.filter('.active'); + index = active.length ? $lis.index(active) : 0; + page = Math.floor(index / realNbPerPage); + hide(); + } + + this.$carousel.on('slide.bs.carousel.gallery_slider', function () { + setTimeout(function () { + var $item = self.$carousel.find('.carousel-inner .carousel-item-prev, .carousel-inner .carousel-item-next'); + var index = $item.index(); + $lis.removeClass('active') + .filter('[data-slide-to="' + index + '"]') + .addClass('active'); + }, 0); + }); + this.$indicator.on('click.gallery_slider', '> li:not([data-slide-to])', function () { + page += ($(this).hasClass('o_indicators_left') ? -1 : 1); + page = Math.max(0, Math.min(nbPages - 1, page)); // should not be necessary + self.$carousel.carousel(page * realNbPerPage); + // We dont use hide() before the slide animation in the editor because there is a traceback + // TO DO: fix this traceback + if (!self.editableMode) { + hide(); + } + }); + this.$carousel.on('slid.bs.carousel.gallery_slider', update); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + + if (!this.$indicator) { + return; + } + + this.$prev.prependTo(this.$indicator); + this.$next.appendTo(this.$indicator); + this.$carousel.off('.gallery_slider'); + this.$indicator.off('.gallery_slider'); + }, +}); + +publicWidget.registry.gallery = GalleryWidget; +publicWidget.registry.gallerySlider = GallerySliderWidget; + +return { + GalleryWidget: GalleryWidget, + GallerySliderWidget: GallerySliderWidget, +}; +}); diff --git a/addons/website/static/src/snippets/s_image_gallery/000.scss b/addons/website/static/src/snippets/s_image_gallery/000.scss new file mode 100644 index 00000000..a99bc86f --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/000.scss @@ -0,0 +1,155 @@ + +.o_gallery:not([data-vcss]) { + &.o_grid, &.o_masonry { + .img { + width: 100%; + } + } + &.o_grid { + &.o_spc-none div.row { + margin: 0; + > div { + padding: 0; + } + } + &.o_spc-small div.row { + margin: 5px 0; + > div { + padding: 0 5px; + } + } + &.o_spc-medium div.row { + margin: 10px 0; + > div { + padding: 0 10px; + } + } + &.o_spc-big div.row { + margin: 15px 0; + > div { + padding: 0 15px; + } + } + &.size-auto .row { + height: auto; + } + &.size-small .row { + height: 100px; + } + &.size-medium .row { + height: 250px; + } + &.size-big .row { + height: 400px; + } + &.size-small, &.size-medium, &.size-big { + img { + height: 100%; + } + } + } + &.o_masonry { + &.o_spc-none div.col { + padding: 0; + > img { + margin: 0 !important; + } + } + &.o_spc-small div.col { + padding: 0 5px; + > img { + margin: 5px 0 !important; + } + } + &.o_spc-medium div.col { + padding: 0 10px; + > img { + margin: 10px 0 !important; + } + } + &.o_spc-big div.col { + padding: 0 15px; + > img { + margin: 15px 0 !important; + } + } + } + &.o_nomode { + &.o_spc-none .img { + padding: 0; + } + &.o_spc-small .img { + padding: 5px; + } + &.o_spc-medium .img { + padding: 10px; + } + &.o_spc-big .img { + padding: 15px; + } + } + &.o_slideshow { + .carousel ul.carousel-indicators li { + border: 1px solid #aaa; + } + > div:first-child { + height: 100%; + } + .carousel { + height: 100%; + + .carousel-inner { + height: 100%; + } + .carousel-item.active, + .carousel-item-next, + .carousel-item-prev { + display: flex; + align-items: center; + height: 100%; + padding-bottom: 64px; + } + img { + max-height: 100%; + max-width: 100%; + margin: auto; + } + ul.carousel-indicators { + height: auto; + padding: 0; + border-width: 0; + position: absolute; + bottom: 0; + width: 100%; + margin-left: 0; + left: 0%; + > * { + list-style-image: none; + display: inline-block; + width: 40px; + height: 40px; + line-height: 40px; + margin: 2.5px 2.5px 2.5px 2.5px; + padding: 0 !important; + border: 1px solid #aaa; + text-indent: initial; + background-size: cover; + background-color: #fff; + border-radius: 0; + vertical-align: bottom; + flex: 0 0 40px; + &:not(.active) { + opacity: 0.8; + filter: grayscale(1); + } + } + } + } + } + .carousel-inner .item img { + max-width: none; + } +} + +// Note: the s_gallery_lightbox is always using the right dom and classes of the +// most recent version of the snippet as it is generated by JS after page load. diff --git a/addons/website/static/src/snippets/s_image_gallery/000.xml b/addons/website/static/src/snippets/s_image_gallery/000.xml new file mode 100644 index 00000000..a50a1af5 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/000.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <!-- + ======================================================================== + Gallery Slideshow + + This template is used to display a slideshow of images inside a + bootstrap carousel. + + ======================================================================== + --> + <t t-name="website.gallery.slideshow"> + <div t-attf-id="#{id}" class="carousel slide" data-ride="carousel" t-attf-data-interval="#{interval}" style="margin: 0 12px;"> + <div class="carousel-inner" style="padding: 0;"> + <t t-foreach="images" t-as="image"> + <div t-attf-class="carousel-item #{image_index == index and 'active' or None}"> + <img t-attf-class="#{attrClass || 'img img-fluid d-block'}" t-att-src="image.src" t-att-style="attrStyle" t-att-alt="image.alt" data-name="Image"/> + </div> + </t> + </div> + + <ul class="carousel-indicators"> + <li class="o_indicators_left text-center d-none" aria-label="Previous" title="Previous"> + <i class="fa fa-chevron-left"/> + </li> + <t t-foreach="images" t-as="image"> + <li t-attf-data-target="##{id}" t-att-data-slide-to="image_index" t-att-class="image_index == index and 'active' or None" t-attf-style="background-image: url(#{image.src})"></li> + </t> + <li class="o_indicators_right text-center d-none" aria-label="Next" title="Next"> + <i class="fa fa-chevron-right"/> + </li> + </ul> + + <a class="carousel-control-prev o_we_no_overlay" t-attf-href="##{id}" data-slide="prev" aria-label="Previous" title="Previous"> + <span class="fa fa-chevron-left fa-2x text-white"></span> + <span class="sr-only">Previous</span> + </a> + <a class="carousel-control-next o_we_no_overlay" t-attf-href="##{id}" data-slide="next" aria-label="Next" title="Next"> + <span class="fa fa-chevron-right fa-2x text-white"></span> + <span class="sr-only">Next</span> + </a> + </div> + </t> + + <!-- + ======================================================================== + Gallery Slideshow LightBox + + This template is used to display a lightbox with a slideshow. + + This template wraps website.gallery.slideshow in a bootstrap modal + dialog. + ======================================================================== + --> + <t t-name="website.gallery.slideshow.lightbox"> + <div role="dialog" class="modal o_technical_modal fade s_gallery_lightbox p-0" aria-labbelledby="Image Gallery Dialog"> + <div class="modal-dialog m-0" role="Picture Gallery" + t-attf-style=""> + <div class="modal-content bg-transparent"> + <main class="modal-body o_slideshow bg-transparent"> + <button type="button" class="close text-white" data-dismiss="modal" style="position: absolute; right: 10px; top: 10px;"><span role="img" aria-label="Close">×</span><span class="sr-only">Close</span></button> + <t t-call="website.gallery.slideshow"></t> + </main> + </div> + </div> + </div> + </t> +</templates> diff --git a/addons/website/static/src/snippets/s_image_gallery/001.scss b/addons/website/static/src/snippets/s_image_gallery/001.scss new file mode 100644 index 00000000..b20d0be3 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/001.scss @@ -0,0 +1,281 @@ + +.s_image_gallery[data-vcss="001"] { + &.o_grid, &.o_masonry { + .img { + width: 100%; + } + } + &.o_grid { + &.o_spc-none div.row { + margin-bottom: 0px; + } + &.o_spc-small div.row > div { + margin-bottom: $spacer; + } + &.o_spc-medium div.row > div { + margin-bottom: $spacer * 2; + } + &.o_spc-big div.row > div { + margin-bottom: $spacer * 3; + } + } + &.o_masonry { + &.o_spc-none div.o_masonry_col { + padding: 0; + > img { + margin: 0 !important; + } + } + &.o_spc-small div.o_masonry_col { + padding: 0 ($spacer * .5); + > img { + margin-bottom: $spacer !important; + } + } + &.o_spc-medium div.o_masonry_col { + padding: 0 $spacer; + > img { + margin-bottom: $spacer * 2 !important; + } + } + &.o_spc-big div.o_masonry_col { + padding: 0 ($spacer * 1.5); + > img { + margin-bottom: $spacer * 3 !important; + } + } + } + &.o_nomode { + &.o_spc-none .row div { + padding-top: 0; + padding-bottom: 0; + } + &.o_spc-small .row div { + padding-top: $spacer * .5; + padding-bottom: $spacer * .5; + } + &.o_spc-medium .row div { + padding-top: $spacer; + padding-bottom: $spacer; + } + &.o_spc-big .row div { + padding-top: $spacer * 1.5; + padding-bottom: $spacer * 1.5; + } + } + &:not(.o_slideshow) { + img { + cursor: pointer; + } + } + &.o_slideshow { + .carousel { + .carousel-item.active, + .carousel-item-next, + .carousel-item-prev, + .carousel-control-next, + .carousel-control-prev { + padding-bottom: 64px; + } + ul.carousel-indicators li { + border: 1px solid #aaa; + } + } + ul.carousel-indicators { + position: absolute; + left: 0%; + bottom: 0; + width: 100%; + height: auto; + margin-left: 0; + padding: 0; + border-width: 0; + > * { + list-style-image: none; + display: inline-block; + width: 40px; + height: 40px; + line-height: 40px; + margin: 2.5px 2.5px 2.5px 2.5px; + padding: 0; + border: 1px solid #aaa; + text-indent: initial; + background-size: cover; + background-color: #fff; + background-position: center; + border-radius: 0; + vertical-align: bottom; + flex: 0 0 40px; + &:not(.active) { + opacity: 0.8; + filter: grayscale(1); + } + } + } + > .container, > .container-fluid, > .o_container_small { + height: 100%; + } + &.s_image_gallery_cover .carousel-item { + > a { + width: 100%; + height: 100%; + } + > a > img, + > img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + &:not(.s_image_gallery_show_indicators) .carousel { + ul.carousel-indicators { + display: none; + } + .carousel-item.active, + .carousel-item-next, + .carousel-item-prev, + .carousel-control-next, + .carousel-control-prev { + padding-bottom: 0px; + } + } + &.s_image_gallery_indicators_arrows_boxed, &.s_image_gallery_indicators_arrows_rounded { + .carousel { + .carousel-control-prev .fa, + .carousel-control-next .fa { + text-shadow: none; + } + } + } + &.s_image_gallery_indicators_arrows_boxed { + .carousel { + .carousel-control-prev .fa:before { + content: "\f104"; + padding-right: 2px; + } + .carousel-control-next .fa:before { + content: "\f105"; + padding-left: 2px; + } + .carousel-control-prev .fa:before, + .carousel-control-next .fa:before { + display: block; + width: 3rem; + height: 3rem; + line-height: 3rem; + color: black; + background: white; + font-size: 1.25rem; + border: 1px solid $gray-500; + } + } + } + &.s_image_gallery_indicators_arrows_rounded { + .carousel { + .carousel-control-prev .fa:before { content: "\f060"; } + .carousel-control-next .fa:before { content: "\f061"; } + .carousel-control-prev .fa:before, + .carousel-control-next .fa:before { + color: black; + background: white; + font-size: 1.25rem; + border-radius: 50%; + padding: 1.25rem; + border: 1px solid $gray-500; + } + } + } + &.s_image_gallery_indicators_rounded { + .carousel { + ul.carousel-indicators li { + border-radius: 50%; + } + } + } + &.s_image_gallery_indicators_dots { + .carousel { + ul.carousel-indicators { + height: 40px; + margin: auto; + + li { + max-width: 8px; + max-height: 8px; + margin: 0 6px; + border-radius: 10px; + background-color: $black; + background-image: none !important; + + &:not(.active) { + opacity: .4; + } + } + } + } + } + + @extend %image-gallery-slideshow-styles; + } + .carousel-inner .item img { + max-width: none; + } +} + +.s_gallery_lightbox { + .close { + font-size: 2rem; + } + .modal-dialog { + height: 100%; + background-color: rgba(0,0,0,0.7); + } + @include media-breakpoint-up(sm) { + .modal-dialog { + max-width: 100%; + padding: 0; + } + } + ul.carousel-indicators { + display: none; + } + + .modal-body.o_slideshow { + @extend %image-gallery-slideshow-styles; + } +} + +%image-gallery-slideshow-styles { + &:not(.s_image_gallery_cover) .carousel-item { + > a { + display: flex; + height: 100%; + width: 100%; + } + > a > img, + > img { + max-height: 100%; + max-width: 100%; + margin: auto; + } + } + .carousel { + height: 100%; + + .carousel-inner { + height: 100%; + } + .carousel-item.active, + .carousel-item-next, + .carousel-item-prev, + .carousel-control-next, + .carousel-control-prev { + display: flex; + align-items: center; + height: 100%; + } + .carousel-control-next .fa, + .carousel-control-prev .fa { + text-shadow: 0px 0px 3px $gray-800; + } + } +} diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js new file mode 100644 index 00000000..8afb5c1f --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/options.js @@ -0,0 +1,481 @@ +odoo.define('website.s_image_gallery_options', function (require) { +'use strict'; + +var core = require('web.core'); +var weWidgets = require('wysiwyg.widgets'); +var options = require('web_editor.snippets.options'); + +var _t = core._t; +var qweb = core.qweb; + +options.registry.gallery = options.Class.extend({ + xmlDependencies: ['/website/static/src/snippets/s_image_gallery/000.xml'], + + /** + * @override + */ + start: function () { + var self = this; + + // Make sure image previews are updated if images are changed + this.$target.on('image_changed', 'img', function (ev) { + var $img = $(ev.currentTarget); + var index = self.$target.find('.carousel-item.active').index(); + self.$('.carousel:first li[data-target]:eq(' + index + ')') + .css('background-image', 'url(' + $img.attr('src') + ')'); + }); + + // When the snippet is empty, an edition button is the default content + // TODO find a nicer way to do that to have editor style + this.$target.on('click', '.o_add_images', function (e) { + e.stopImmediatePropagation(); + self.addImages(false); + }); + + this.$target.on('dropped', 'img', function (ev) { + self.mode(null, self.getMode()); + if (!ev.target.height) { + $(ev.target).one('load', function () { + setTimeout(function () { + self.trigger_up('cover_update'); + }); + }); + } + }); + + const $container = this.$('> .container, > .container-fluid, > .o_container_small'); + if ($container.find('> *:not(div)').length) { + self.mode(null, self.getMode()); + } + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + if (this.$target.find('.o_add_images').length) { + this.addImages(false); + } + // TODO should consider the async parts + this._adaptNavigationIDs(); + }, + /** + * @override + */ + onClone: function () { + this._adaptNavigationIDs(); + }, + /** + * @override + */ + cleanForSave: function () { + if (this.$target.hasClass('slideshow')) { + this.$target.removeAttr('style'); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Allows to select images to add as part of the snippet. + * + * @see this.selectClass for parameters + */ + addImages: function (previewMode) { + const $images = this.$('img'); + var $container = this.$('> .container, > .container-fluid, > .o_container_small'); + var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true, mediaWidth: 1920}); + var lastImage = _.last(this._getImages()); + var index = lastImage ? this._getIndex(lastImage) : -1; + return new Promise(resolve => { + dialog.on('save', this, function (attachments) { + for (var i = 0; i < attachments.length; i++) { + $('<img/>', { + class: $images.length > 0 ? $images[0].className : 'img img-fluid d-block ', + src: attachments[i].image_src, + 'data-index': ++index, + alt: attachments[i].description || '', + 'data-name': _t('Image'), + style: $images.length > 0 ? $images[0].style.cssText : '', + }).appendTo($container); + } + if (attachments.length > 0) { + this.mode('reset', this.getMode()); + this.trigger_up('cover_update'); + } + }); + dialog.on('closed', this, () => resolve()); + dialog.open(); + }); + }, + /** + * Allows to change the number of columns when displaying images with a + * grid-like layout. + * + * @see this.selectClass for parameters + */ + columns: function (previewMode, widgetValue, params) { + const nbColumns = parseInt(widgetValue || '1'); + this.$target.attr('data-columns', nbColumns); + + this.mode(previewMode, this.getMode(), {}); // TODO improve + }, + /** + * Get the image target's layout mode (slideshow, masonry, grid or nomode). + * + * @returns {String('slideshow'|'masonry'|'grid'|'nomode')} + */ + getMode: function () { + var mode = 'slideshow'; + if (this.$target.hasClass('o_masonry')) { + mode = 'masonry'; + } + if (this.$target.hasClass('o_grid')) { + mode = 'grid'; + } + if (this.$target.hasClass('o_nomode')) { + mode = 'nomode'; + } + return mode; + }, + /** + * Displays the images with the "grid" layout. + */ + grid: function () { + var imgs = this._getImages(); + var $row = $('<div/>', {class: 'row s_nb_column_fixed'}); + var columns = this._getColumns(); + var colClass = 'col-lg-' + (12 / columns); + var $container = this._replaceContent($row); + + _.each(imgs, function (img, index) { + var $img = $(img); + var $col = $('<div/>', {class: colClass}); + $col.append($img).appendTo($row); + if ((index + 1) % columns === 0) { + $row = $('<div/>', {class: 'row s_nb_column_fixed'}); + $row.appendTo($container); + } + }); + this.$target.css('height', ''); + }, + /** + * Displays the images with the "masonry" layout. + */ + masonry: function () { + var self = this; + var imgs = this._getImages(); + var columns = this._getColumns(); + var colClass = 'col-lg-' + (12 / columns); + var cols = []; + + var $row = $('<div/>', {class: 'row s_nb_column_fixed'}); + this._replaceContent($row); + + // Create columns + for (var c = 0; c < columns; c++) { + var $col = $('<div/>', {class: 'o_masonry_col o_snippet_not_selectable ' + colClass}); + $row.append($col); + cols.push($col[0]); + } + + // Dispatch images in columns by always putting the next one in the + // smallest-height column + while (imgs.length) { + var min = Infinity; + var $lowest; + _.each(cols, function (col) { + var $col = $(col); + var height = $col.is(':empty') ? 0 : $col.find('img').last().offset().top + $col.find('img').last().height() - self.$target.offset().top; + if (height < min) { + min = height; + $lowest = $col; + } + }); + $lowest.append(imgs.shift()); + } + }, + /** + * Allows to change the images layout. @see grid, masonry, nomode, slideshow + * + * @see this.selectClass for parameters + */ + mode: function (previewMode, widgetValue, params) { + widgetValue = widgetValue || 'slideshow'; // FIXME should not be needed + this.$target.css('height', ''); + this.$target + .removeClass('o_nomode o_masonry o_grid o_slideshow') + .addClass('o_' + widgetValue); + this[widgetValue](); + this.trigger_up('cover_update'); + this._refreshPublicWidgets(); + }, + /** + * Displays the images with the standard layout: floating images. + */ + nomode: function () { + var $row = $('<div/>', {class: 'row s_nb_column_fixed'}); + var imgs = this._getImages(); + + this._replaceContent($row); + + _.each(imgs, function (img) { + var wrapClass = 'col-lg-3'; + if (img.width >= img.height * 2 || img.width > 600) { + wrapClass = 'col-lg-6'; + } + var $wrap = $('<div/>', {class: wrapClass}).append(img); + $row.append($wrap); + }); + }, + /** + * Allows to remove all images. Restores the snippet to the way it was when + * it was added in the page. + * + * @see this.selectClass for parameters + */ + removeAllImages: function (previewMode) { + var $addImg = $('<div>', { + class: 'alert alert-info css_non_editable_mode_hidden text-center', + }); + var $text = $('<span>', { + class: 'o_add_images', + style: 'cursor: pointer;', + text: _t(" Add Images"), + }); + var $icon = $('<i>', { + class: ' fa fa-plus-circle', + }); + this._replaceContent($addImg.append($icon).append($text)); + }, + /** + * Displays the images with a "slideshow" layout. + */ + slideshow: function () { + const imageEls = this._getImages(); + const images = _.map(imageEls, img => ({ + // Use getAttribute to get the attribute value otherwise .src + // returns the absolute url. + src: img.getAttribute('src'), + alt: img.getAttribute('alt'), + })); + var currentInterval = this.$target.find('.carousel:first').attr('data-interval'); + var params = { + images: images, + index: 0, + title: "", + interval: currentInterval || 0, + id: 'slideshow_' + new Date().getTime(), + attrClass: imageEls.length > 0 ? imageEls[0].className : '', + attrStyle: imageEls.length > 0 ? imageEls[0].style.cssText : '', + }, + $slideshow = $(qweb.render('website.gallery.slideshow', params)); + this._replaceContent($slideshow); + _.each(this.$('img'), function (img, index) { + $(img).attr({contenteditable: true, 'data-index': index}); + }); + this.$target.css('height', Math.round(window.innerHeight * 0.7)); + + // Apply layout animation + this.$target.off('slide.bs.carousel').off('slid.bs.carousel'); + this.$('li.fa').off('click'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Handles image removals and image index updates. + * + * @override + */ + notify: function (name, data) { + this._super(...arguments); + if (name === 'image_removed') { + data.$image.remove(); // Force the removal of the image before reset + this.mode('reset', this.getMode()); + } else if (name === 'image_index_request') { + var imgs = this._getImages(); + var position = _.indexOf(imgs, data.$image[0]); + imgs.splice(position, 1); + switch (data.position) { + case 'first': + imgs.unshift(data.$image[0]); + break; + case 'prev': + imgs.splice(position - 1, 0, data.$image[0]); + break; + case 'next': + imgs.splice(position + 1, 0, data.$image[0]); + break; + case 'last': + imgs.push(data.$image[0]); + break; + } + position = imgs.indexOf(data.$image[0]); + _.each(imgs, function (img, index) { + // Note: there might be more efficient ways to do that but it is + // more simple this way and allows compatibility with 10.0 where + // indexes were not the same as positions. + $(img).attr('data-index', index); + }); + const currentMode = this.getMode(); + this.mode('reset', currentMode); + if (currentMode === 'slideshow') { + const $carousel = this.$target.find('.carousel'); + $carousel.removeClass('slide'); + $carousel.carousel(position); + this.$target.find('.carousel-indicators li').removeClass('active'); + this.$target.find('.carousel-indicators li[data-slide-to="' + position + '"]').addClass('active'); + this.trigger_up('activate_snippet', { + $snippet: this.$target.find('.carousel-item.active img'), + ifInactiveOptions: true, + }); + $carousel.addClass('slide'); + } else { + this.trigger_up('activate_snippet', { + $snippet: data.$image, + ifInactiveOptions: true, + }); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _adaptNavigationIDs: function () { + var uuid = new Date().getTime(); + this.$target.find('.carousel').attr('id', 'slideshow_' + uuid); + _.each(this.$target.find('[data-slide], [data-slide-to]'), function (el) { + var $el = $(el); + if ($el.attr('data-target')) { + $el.attr('data-target', '#slideshow_' + uuid); + } else if ($el.attr('href')) { + $el.attr('href', '#slideshow_' + uuid); + } + }); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'mode': { + let activeModeName = 'slideshow'; + for (const modeName of params.possibleValues) { + if (this.$target.hasClass(`o_${modeName}`)) { + activeModeName = modeName; + break; + } + } + this.activeMode = activeModeName; + return activeModeName; + } + case 'columns': { + return `${this._getColumns()}`; + } + } + return this._super(...arguments); + }, + /** + * @private + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'slideshow_mode_opt') { + return false; + } + return this._super(...arguments); + }, + /** + * Returns the images, sorted by index. + * + * @private + * @returns {DOMElement[]} + */ + _getImages: function () { + var imgs = this.$('img').get(); + var self = this; + imgs.sort(function (a, b) { + return self._getIndex(a) - self._getIndex(b); + }); + return imgs; + }, + /** + * Returns the index associated to a given image. + * + * @private + * @param {DOMElement} img + * @returns {integer} + */ + _getIndex: function (img) { + return img.dataset.index || 0; + }, + /** + * Returns the currently selected column option. + * + * @private + * @returns {integer} + */ + _getColumns: function () { + return parseInt(this.$target.attr('data-columns')) || 3; + }, + /** + * Empties the container, adds the given content and returns the container. + * + * @private + * @param {jQuery} $content + * @returns {jQuery} the main container of the snippet + */ + _replaceContent: function ($content) { + var $container = this.$('> .container, > .container-fluid, > .o_container_small'); + $container.empty().append($content); + return $container; + }, +}); + +options.registry.gallery_img = options.Class.extend({ + /** + * Rebuilds the whole gallery when one image is removed. + * + * @override + */ + onRemove: function () { + this.trigger_up('option_update', { + optionName: 'gallery', + name: 'image_removed', + data: { + $image: this.$target, + }, + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Allows to change the position of an image (its order in the image set). + * + * @see this.selectClass for parameters + */ + position: function (previewMode, widgetValue, params) { + this.trigger_up('option_update', { + optionName: 'gallery', + name: 'image_index_request', + data: { + $image: this.$target, + position: widgetValue, + }, + }); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_masonry_block/000.scss b/addons/website/static/src/snippets/s_masonry_block/000.scss new file mode 100644 index 00000000..19e10320 --- /dev/null +++ b/addons/website/static/src/snippets/s_masonry_block/000.scss @@ -0,0 +1,93 @@ +.s_masonry_block:not([data-vcss]) { + .row, .row > div { + display: flex; + } + + .row > div { + padding-bottom: $grid-gutter-width/2; + padding-top: $grid-gutter-width/2; + justify-content: center; + flex-flow: column wrap; + } + + .container-fluid > .row { + flex-flow: row nowrap; + + @include media-breakpoint-down(md) { + flex-flow: column nowrap; + } + + > div.s_masonry_block_pseudo_col { + flex: 1 1 auto; + padding:0; + + > .row { + flex-flow: row wrap; + min-height: 100%; + margin: 0; + + @include media-breakpoint-down(sm) { + flex-flow: column nowrap; + } + + > div { + min-height: 50%; + flex: 1 1 auto; + } + } + } + } + + &.s_ratio_2_1 { + .row > div { + padding-top: $grid-gutter-width; + padding-bottom: $grid-gutter-width; + } + } +} + +html[data-no-flex] .s_masonry_block:not([data-vcss]) { + min-height: 340px; + > div { + height: 100%; + } + + .row { + height: 100%; + + > div { + position: relative; + height: 100%; + min-height: 170px; + padding-top: 0; + padding-left: 0; + } + } + + .content { + @include clearfix; + } + + @include media-breakpoint-up(md) { + .row .row > div { + height: 50%; + } + } + + @include media-breakpoint-up(lg) { + height: 0px; // hack to force height chain + &.s_ratio_2_1 { + position: relative; + padding: 0 0 50% 0; // to have 2:1 aspect ratio + > div { + padding-top: 0; + padding-bottom:0; + @include o-position-absolute(0, 0, 0, 0); + } + } + + .content { + @include o-position-absolute($s-masonry-block-content-top, $s-masonry-block-content-right, $s-masonry-block-content-bottom, $s-masonry-block-content-left); + } + } +} diff --git a/addons/website/static/src/snippets/s_masonry_block/000_variables.scss b/addons/website/static/src/snippets/s_masonry_block/000_variables.scss new file mode 100644 index 00000000..2261e63a --- /dev/null +++ b/addons/website/static/src/snippets/s_masonry_block/000_variables.scss @@ -0,0 +1,4 @@ +$s-masonry-block-content-top: 35%; +$s-masonry-block-content-right: 25%; +$s-masonry-block-content-bottom: 35%; +$s-masonry-block-content-left: 25%; diff --git a/addons/website/static/src/snippets/s_masonry_block/001.scss b/addons/website/static/src/snippets/s_masonry_block/001.scss new file mode 100644 index 00000000..f5020295 --- /dev/null +++ b/addons/website/static/src/snippets/s_masonry_block/001.scss @@ -0,0 +1,5 @@ +.s_masonry_block[data-vcss='001'] .row > div { + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/addons/website/static/src/snippets/s_media_list/000.scss b/addons/website/static/src/snippets/s_media_list/000.scss new file mode 100644 index 00000000..6c22e352 --- /dev/null +++ b/addons/website/static/src/snippets/s_media_list/000.scss @@ -0,0 +1,27 @@ + +.s_media_list:not([data-vcss]) { + .row { + margin: $grid-gutter-width 0; + } + [class*="col-"] { + padding: 0; + } + .s_media_list_body { + padding: $grid-gutter-width; + background-color: gray('white'); + } + .s_media_list_options { + @include o-position-absolute(auto,0,0,0); + display: flex; + border-top: 1px solid gray('400'); + .s_media_list_option { + flex: 1 1 auto; + padding: $grid-gutter-width/3 0; + border-right: 1px solid gray('400'); + text-align: center; + &:last-child { + border-right: 0; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_media_list/001.scss b/addons/website/static/src/snippets/s_media_list/001.scss new file mode 100644 index 00000000..aae95e2c --- /dev/null +++ b/addons/website/static/src/snippets/s_media_list/001.scss @@ -0,0 +1,11 @@ +.s_media_list[data-vcss="001"] { + .s_media_list_item > .row { + overflow: hidden; // To support rounded option + } + .s_media_list_body { + padding: $spacer * 2; + } + .s_media_list_img { + object-fit: cover; + } +} diff --git a/addons/website/static/src/snippets/s_media_list/options.js b/addons/website/static/src/snippets/s_media_list/options.js new file mode 100644 index 00000000..ee277fcf --- /dev/null +++ b/addons/website/static/src/snippets/s_media_list/options.js @@ -0,0 +1,50 @@ +odoo.define('website.s_media_list_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.MediaItemLayout = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change the media item layout. + * + * @see this.selectClass for parameters + */ + layout: function (previewMode, widgetValue, params) { + const $image = this.$target.find('.s_media_list_img_wrapper'); + const $content = this.$target.find('.s_media_list_body'); + + for (const possibleValue of params.possibleValues) { + $image.removeClass(`col-lg-${possibleValue}`); + $content.removeClass(`col-lg-${12 - possibleValue}`); + } + $image.addClass(`col-lg-${widgetValue}`); + $content.addClass(`col-lg-${12 - widgetValue}`); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'layout': { + const $image = this.$target.find('.s_media_list_img_wrapper'); + for (const possibleValue of params.possibleValues) { + if ($image.hasClass(`col-lg-${possibleValue}`)) { + return possibleValue; + } + } + } + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_popup/000.js b/addons/website/static/src/snippets/s_popup/000.js new file mode 100644 index 00000000..2b11466e --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/000.js @@ -0,0 +1,119 @@ +odoo.define('website.s_popup', function (require) { +'use strict'; + +const config = require('web.config'); +const publicWidget = require('web.public.widget'); +const utils = require('web.utils'); + +const PopupWidget = publicWidget.Widget.extend({ + selector: '.s_popup', + events: { + 'click .js_close_popup': '_onCloseClick', + 'hide.bs.modal': '_onHideModal', + }, + + /** + * @override + */ + start: function () { + this._popupAlreadyShown = !!utils.get_cookie(this.$el.attr('id')); + if (!this._popupAlreadyShown) { + this._bindPopup(); + } + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(document).off('mouseleave.open_popup'); + this.$target.find('.modal').modal('hide'); + clearTimeout(this.timeout); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _bindPopup: function () { + const $main = this.$target.find('.modal'); + + let display = $main.data('display'); + let delay = $main.data('showAfter'); + + if (config.device.isMobile) { + if (display === 'mouseExit') { + display = 'afterDelay'; + delay = 5000; + } + this.$('.modal').removeClass('s_popup_middle').addClass('s_popup_bottom'); + } + + if (display === 'afterDelay') { + this.timeout = setTimeout(() => this._showPopup(), delay); + } else { + $(document).on('mouseleave.open_popup', () => this._showPopup()); + } + }, + /** + * @private + */ + _hidePopup: function () { + this.$target.find('.modal').modal('hide'); + }, + /** + * @private + */ + _showPopup: function () { + if (this._popupAlreadyShown) { + return; + } + this.$target.find('.modal').modal('show'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCloseClick: function () { + this._hidePopup(); + }, + /** + * @private + */ + _onHideModal: function () { + const nbDays = this.$el.find('.modal').data('consentsDuration'); + utils.set_cookie(this.$el.attr('id'), true, nbDays * 24 * 60 * 60); + this._popupAlreadyShown = true; + }, +}); + +publicWidget.registry.popup = PopupWidget; + +// Prevent bootstrap to prevent scrolling and to add the strange body +// padding-right they add if the popup does not use a backdrop (especially +// important for default cookie bar). +const _baseSetScrollbar = $.fn.modal.Constructor.prototype._setScrollbar; +$.fn.modal.Constructor.prototype._setScrollbar = function () { + if (this._element.classList.contains('s_popup_no_backdrop')) { + return; + } + return _baseSetScrollbar.apply(this, ...arguments); +}; +const _baseGetScrollbarWidth = $.fn.modal.Constructor.prototype._getScrollbarWidth; +$.fn.modal.Constructor.prototype._getScrollbarWidth = function () { + if (this._element.classList.contains('s_popup_no_backdrop')) { + return 0; + } + return _baseGetScrollbarWidth.apply(this, ...arguments); +}; + +return PopupWidget; +}); diff --git a/addons/website/static/src/snippets/s_popup/000.scss b/addons/website/static/src/snippets/s_popup/000.scss new file mode 100644 index 00000000..02738156 --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/000.scss @@ -0,0 +1,99 @@ +// s_popup +.s_popup_main:not([data-vcss]) { + .s_popup_content { + // keep lower than <p> height (cookies bar) + min-height: $o-font-size-base * $o-line-height-base; + box-shadow: $modal-content-box-shadow-sm-up; + .container { + // keep margin when fixed bottom + @include make-container(); + } + } + + &.modal:not(.d-none) { + display: block !important; + } + + $popup-close-size: 1.5rem; + .s_popup_close { + position: absolute; + background: white; + height: $popup-close-size; + width: $popup-close-size; + line-height: $popup-close-size; + margin-top: -1 * $popup-close-size / 2; + margin-right: -1 * $popup-close-size / 2; + border-radius: $popup-close-size / 2; + right: 0px; + top: 0px; + box-shadow: rgba(0,0,0,0.8) 0 0 5px; + cursor: pointer; + text-align: center; + z-index: 1; + font-size: $popup-close-size; + } + + &.s_popup_center { + .s_popup_full { + @include o-position-absolute(0, 0, 0, 0); + &.modal-dialog { + max-width: 100%; + padding: 0 !important; + margin: 0 !important; + + .modal-content { + height: 100%; + width: 100%; + justify-content: center; + } + } + .s_popup_close { + font-size: 60px; + margin: 10px; + background: none; + box-shadow: none; + } + } + } + + &.s_popup_fixed { + &.s_popup_fixed_top { + .s_popup_content { + top: $o-navbar-height; + } + } + &:not(.s_popup_fixed_top) { + .s_popup_content { + bottom: 0; + } + } + .s_popup_content { + z-index: $zindex-modal; + position: fixed; + right: 20px; + } + .modal-sm .s_popup_content { + width: $modal-sm; + } + .modal-md .s_popup_content { + width: $o-modal-md; + } + .modal-lg .s_popup_content { + width: $o-modal-lg; + } + .modal-xl .s_popup_content { + width: $modal-xl; + } + .s_popup_full .s_popup_content { + right: 0; + left: 0; + .s_popup_close { + box-shadow: none; + font-size: 60px; + margin: 10px; + background: none; + } + + } + } +} diff --git a/addons/website/static/src/snippets/s_popup/001.scss b/addons/website/static/src/snippets/s_popup/001.scss new file mode 100644 index 00000000..f8ae94ca --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/001.scss @@ -0,0 +1,72 @@ +.s_popup[data-vcss='001'] { + .modal-content { + min-height: $font-size-lg * 2; + max-height: none; + border: 0; + border-radius: 0; + box-shadow: $modal-content-box-shadow-sm-up; + } + + .modal-dialog { + height: auto; + min-height: 100%; + } + + // Close icon + .s_popup_close { + z-index: $zindex-modal; + @include o-position-absolute(0, 0); + width: $font-size-lg * 2; + height: $font-size-lg * 2; + line-height: $font-size-lg * 2; + @include o-bg-color(color-yiq(o-color('primary')), o-color('primary'), $with-extras: false); + box-shadow: $box-shadow-sm; + cursor: pointer; + font-size: $font-size-lg; + text-align: center; + } + + // Size option - Full + .s_popup_size_full { + padding: 0 !important; + max-width: 100%; + + > .modal-content { + // Use the backdrop color as background-color + background-color: transparent; + box-shadow: none; + border-radius: 0; + } + } + + // Position option - Middle + .s_popup_middle .modal-dialog { + align-items: center; + } + + // Position option - Top/Bottom + .s_popup_top, + .s_popup_bottom { + .modal-dialog { + margin-right: 0; + &:not(.s_popup_size_full) { + padding: $spacer !important; + } + } + } + .s_popup_top .modal-dialog { + align-items: flex-start; + } + .s_popup_bottom .modal-dialog { + align-items: flex-end; + } + + // No backdrop + .s_popup_no_backdrop { + pointer-events: none; + + .modal-content { + pointer-events: auto; + } + } +} diff --git a/addons/website/static/src/snippets/s_popup/options.js b/addons/website/static/src/snippets/s_popup/options.js new file mode 100644 index 00000000..f31c00d3 --- /dev/null +++ b/addons/website/static/src/snippets/s_popup/options.js @@ -0,0 +1,114 @@ +odoo.define('website.s_popup_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.SnippetPopup = options.Class.extend({ + /** + * @override + */ + start: function () { + // Note: the link are excluded here so that internal modal buttons do + // not close the popup as we want to allow edition of those buttons. + this.$target.on('click.SnippetPopup', '.js_close_popup:not(a, .btn)', ev => { + ev.stopPropagation(); + this.onTargetHide(); + this.trigger_up('snippet_option_visibility_update', {show: false}); + }); + this.$target.on('shown.bs.modal.SnippetPopup', () => { + this.trigger_up('snippet_option_visibility_update', {show: true}); + }); + this.$target.on('hidden.bs.modal.SnippetPopup', () => { + this.trigger_up('snippet_option_visibility_update', {show: false}); + }); + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$target.off('.SnippetPopup'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onTargetShow: async function () { + this.$target.modal('show'); + $(document.body).children('.modal-backdrop:last').addClass('d-none'); + }, + /** + * @override + */ + onTargetHide: async function () { + return new Promise(resolve => { + const timeoutID = setTimeout(() => { + this.$target.off('hidden.bs.modal.popup_on_target_hide'); + resolve(); + }, 500); + this.$target.one('hidden.bs.modal.popup_on_target_hide', () => { + clearTimeout(timeoutID); + resolve(); + }); + this.$target.modal('hide'); + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet in footer to be common to all pages + * or inside wrap to be on one page only + * + * @see this.selectClass for parameters + */ + moveBlock: function (previewMode, widgetValue, params) { + const $container = $(widgetValue === 'moveToFooter' ? 'footer' : 'main'); + this.$target.closest('.s_popup').prependTo($container.find('.oe_structure:o_editable').first()); + }, + /** + * @see this.selectClass for parameters + */ + setBackdrop(previewMode, widgetValue, params) { + const color = widgetValue ? 'var(--black-50)' : ''; + this.$target[0].style.setProperty('background-color', color, 'important'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID. + * + * @private + */ + _assignUniqueID: function () { + this.$target.closest('.s_popup').attr('id', 'sPopup' + Date.now()); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'moveBlock': + return this.$target.closest('footer').length ? 'moveToFooter' : 'moveToBody'; + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_process_steps/000.scss b/addons/website/static/src/snippets/s_process_steps/000.scss new file mode 100644 index 00000000..3daae1d9 --- /dev/null +++ b/addons/website/static/src/snippets/s_process_steps/000.scss @@ -0,0 +1,52 @@ +.s_process_steps { + .s_process_step_icon { + margin: $grid-gutter-width 0; + span { + display: block; + overflow: hidden; + } + .fa { + display: block; + } + } + .s_process_step_content { + padding: 0 $grid-gutter-width/2; + } + @include media-breakpoint-up(lg) { + overflow-x: hidden; + .s_process_step { + .s_process_step_icon { + position: relative; + z-index: 1; + span:after { + content: ''; + z-index: -1; + border-top: 1px solid gray('500'); + @include o-position-absolute(50%, 0, 0, auto); + } + } + .s_process_step_icon { + span:after { + width: 100%; + } + } + &:first-child .s_process_step_icon, + &:last-child .s_process_step_icon { + span:after { + width: 50%; + } + } + &:first-child .s_process_step_icon { + .fa:after { right: 0; } + .fa.float-right:after { width: 0; } + } + &:last-child .s_process_step_icon { + span:after { left: 0; } + .fa { + &:after { left: 0; } + &.float-left:after { width: 0; } + } + } + } + } +} diff --git a/addons/website/static/src/snippets/s_product_catalog/001.scss b/addons/website/static/src/snippets/s_product_catalog/001.scss new file mode 100644 index 00000000..7e0b2350 --- /dev/null +++ b/addons/website/static/src/snippets/s_product_catalog/001.scss @@ -0,0 +1,29 @@ +.s_product_catalog[data-vcss='001'] { + .s_product_catalog_dish { + // Title + .s_product_catalog_dish_title { + line-height: $headings-line-height; + } + // Description + .s_product_catalog_dish_description { + margin-bottom: $spacer; + } + &:last-child { + .s_product_catalog_dish_description { + margin-bottom: 0; + } + } + // Dot Leaders + .s_product_catalog_dish_dot_leaders { + display: flex; + flex-grow: 1; + align-items: center; + &::after { + content: ''; + margin-left: $spacer/2; + flex: 1 0 auto; + border-bottom: 1px dotted; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_product_catalog/options.js b/addons/website/static/src/snippets/s_product_catalog/options.js new file mode 100644 index 00000000..ef78a364 --- /dev/null +++ b/addons/website/static/src/snippets/s_product_catalog/options.js @@ -0,0 +1,66 @@ + +odoo.define('website.s_product_catalog_options', function (require) { +'use strict'; + +const core = require('web.core'); +const options = require('web_editor.snippets.options'); + +const _t = core._t; + +options.registry.ProductCatalog = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Show/hide descriptions. + * + * @see this.selectClass for parameters + */ + toggleDescription: function (previewMode, widgetValue, params) { + const $dishes = this.$('.s_product_catalog_dish'); + const $name = $dishes.find('.s_product_catalog_dish_name'); + $name.toggleClass('s_product_catalog_dish_dot_leaders', !widgetValue); + if (widgetValue) { + _.each($dishes, el => { + const $description = $(el).find('.s_product_catalog_dish_description'); + if ($description.length) { + $description.removeClass('d-none'); + } else { + const descriptionEl = document.createElement('p'); + descriptionEl.classList.add('s_product_catalog_dish_description', 'border-top', 'text-muted', 'pt-1', 'o_default_snippet_text'); + const iEl = document.createElement('i'); + iEl.textContent = _t("Add a description here"); + descriptionEl.appendChild(iEl); + el.appendChild(descriptionEl); + } + }); + } else { + _.each($dishes, el => { + const $description = $(el).find('.s_product_catalog_dish_description'); + if ($description.hasClass('o_default_snippet_text') || $description.find('.o_default_snippet_text').length) { + $description.remove(); + } else { + $description.addClass('d-none'); + } + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'toggleDescription') { + const $description = this.$('.s_product_catalog_dish_description'); + return $description.length && !$description.hasClass('d-none'); + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_product_list/000.scss b/addons/website/static/src/snippets/s_product_list/000.scss new file mode 100644 index 00000000..480a58ec --- /dev/null +++ b/addons/website/static/src/snippets/s_product_list/000.scss @@ -0,0 +1,45 @@ + +.s_product_list { + padding-top: 20px; + + > div > .row > div { + margin-bottom: 20px; // without this style the columns go directly to the top of the bellow ones. + + height: 200px; + text-align: center; + + a { + display: block; + } + + img { + margin: auto; + max-height: 130px; + @include s-product-list-img-hook; + } + + .s_product_list_item_link { + @include o-position-absolute($left: 10%, $bottom: 0, $right: 10%); + + > .btn { + width: 100%; + padding: 5px !important; + font-size: 16px; + + @media only screen and (max-width : 1280px) { // FIXME + font-size: 12px; + } + + .fa { + font-size: 18px; + padding-right: 5px; + + @media only screen and (max-width : 1024px) { // FIXME + display: block; + font-size: 25px; + } + } + } + } + } +} diff --git a/addons/website/static/src/snippets/s_product_list/000_variables.scss b/addons/website/static/src/snippets/s_product_list/000_variables.scss new file mode 100644 index 00000000..8ce4e819 --- /dev/null +++ b/addons/website/static/src/snippets/s_product_list/000_variables.scss @@ -0,0 +1 @@ +@mixin s-product-list-img-hook {} diff --git a/addons/website/static/src/snippets/s_progress_bar/options.js b/addons/website/static/src/snippets/s_progress_bar/options.js new file mode 100644 index 00000000..d3f544a0 --- /dev/null +++ b/addons/website/static/src/snippets/s_progress_bar/options.js @@ -0,0 +1,80 @@ +odoo.define('website.s_progress_bar_options', function (require) { +'use strict'; + +const core = require('web.core'); +const utils = require('web.utils'); +const options = require('web_editor.snippets.options'); + +const _t = core._t; + +options.registry.progress = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the position of the progressbar text. + * + * @see this.selectClass for parameters + */ + display: function (previewMode, widgetValue, params) { + // retro-compatibility + if (this.$target.hasClass('progress')) { + this.$target.removeClass('progress'); + this.$target.find('.progress-bar').wrap($('<div/>', { + class: 'progress', + })); + this.$target.find('.progress-bar span').addClass('s_progress_bar_text'); + } + + let $text = this.$target.find('.s_progress_bar_text'); + if (!$text.length) { + $text = $('<span/>').addClass('s_progress_bar_text').html(_t('80% Development')); + } + + if (widgetValue === 'inline') { + $text.appendTo(this.$target.find('.progress-bar')); + } else { + $text.insertBefore(this.$target.find('.progress')); + } + }, + /** + * Sets the progress bar value. + * + * @see this.selectClass for parameters + */ + progressBarValue: function (previewMode, widgetValue, params) { + let value = parseInt(widgetValue); + value = utils.confine(value, 0, 100); + const $progressBar = this.$target.find('.progress-bar'); + const $progressBarText = this.$target.find('.s_progress_bar_text'); + // Target precisely the XX% not only XX to not replace wrong element + // eg 'Since 1978 we have completed 45%' <- don't replace 1978 + $progressBarText.text($progressBarText.text().replace(/[0-9]+%/, value + '%')); + $progressBar.attr("aria-valuenow", value); + $progressBar.css("width", value + "%"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'display': { + const isInline = this.$target.find('.s_progress_bar_text') + .parent('.progress-bar').length; + return isInline ? 'inline' : 'below'; + } + case 'progressBarValue': { + return this.$target.find('.progress-bar').attr('aria-valuenow') + '%'; + } + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_quotes_carousel/000.scss b/addons/website/static/src/snippets/s_quotes_carousel/000.scss new file mode 100644 index 00000000..8da0a16c --- /dev/null +++ b/addons/website/static/src/snippets/s_quotes_carousel/000.scss @@ -0,0 +1,22 @@ +.s_quotes_carousel:not([data-vcss]) { + blockquote { + padding: $grid-gutter-width; + margin-bottom: 0; + .s_quotes_carousel_icon { + position: absolute; + top: 0; + left: -3rem; + } + img { + max-width: 40px; + margin-right: 5px; + border-radius: 50%; + } + footer { + background-color: transparent; + &:before { + content:""; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_quotes_carousel/001.scss b/addons/website/static/src/snippets/s_quotes_carousel/001.scss new file mode 100644 index 00000000..c26dff12 --- /dev/null +++ b/addons/website/static/src/snippets/s_quotes_carousel/001.scss @@ -0,0 +1,8 @@ +.s_quotes_carousel_wrapper[data-vcss='001'] { + .s_blockquote { + margin-bottom: 0; + @include media-breakpoint-down(sm) { + width: 100% !important; // TODO add the right class in the xml when it's possible + } + } +} diff --git a/addons/website/static/src/snippets/s_rating/000.scss b/addons/website/static/src/snippets/s_rating/000.scss new file mode 100644 index 00000000..968ee7d3 --- /dev/null +++ b/addons/website/static/src/snippets/s_rating/000.scss @@ -0,0 +1,55 @@ + +.s_rating:not([data-vcss]) { + $star: "\f005"; + $star-o: "\f006"; + $circle: "\f111"; + $circle-o: "\f10c"; + $heart: "\f004"; + $heart-o: "\f08a"; + @mixin s_rating_generate_icons($off, $on) { + .fa:before { + content: $off; + } + @for $counter from 5 to 0 { + &.s_rating_#{$counter} { + .fa:nth-of-type(-n+#{$counter}):before { + content: $on; + } + } + } + } + > .s_rating_stars { @include s_rating_generate_icons($star-o, $star); } + > .s_rating_squares { @include s_rating_generate_icons($circle-o, $circle); } + > .s_rating_hearts { @include s_rating_generate_icons($heart-o, $heart); } + > .s_rating_bar { + .fa { + display: none; + } + .s_rating_bar { + display: flex; + height: $progress-height; + background-color: $gray-300; + &:before { + content: ""; + display: flex; + flex-direction: column; + justify-content: center; + @include transition($progress-bar-transition); + @include gradient-striped(); + background-size: $progress-height $progress-height; + background-color: theme-color('primary'); + animation: progress-bar-stripes $progress-bar-animation-timing; + } + } + @for $counter from 5 to 0 { + &.s_rating_#{$counter} { + .s_rating_bar:before { + width: percentage($counter/5); + } + } + } + } + > .s_rating_1x { .fa { font-size: 1em; }; } + > .s_rating_2x { .fa { font-size: 2em; }; } + > .s_rating_3x { .fa { font-size: 3em; }; } +} diff --git a/addons/website/static/src/snippets/s_rating/001.scss b/addons/website/static/src/snippets/s_rating/001.scss new file mode 100644 index 00000000..a1fc87c0 --- /dev/null +++ b/addons/website/static/src/snippets/s_rating/001.scss @@ -0,0 +1,15 @@ + +.s_rating[data-vcss="001"] { + &.s_rating_inline { + display: flex; + align-items: center; + + .s_rating_title { + margin: 0; + margin-right: 0.5em; + } + .s_rating_icons { + margin-left: auto; + } + } +} diff --git a/addons/website/static/src/snippets/s_rating/options.js b/addons/website/static/src/snippets/s_rating/options.js new file mode 100644 index 00000000..b24d7daf --- /dev/null +++ b/addons/website/static/src/snippets/s_rating/options.js @@ -0,0 +1,151 @@ +odoo.define('website.s_rating_options', function (require) { +'use strict'; + +const weWidgets = require('wysiwyg.widgets'); +const options = require('web_editor.snippets.options'); + +options.registry.Rating = options.Class.extend({ + /** + * @override + */ + start: function () { + this.iconType = this.$target[0].dataset.icon; + this.faClassActiveCustomIcons = this.$target[0].dataset.activeCustomIcon || ''; + this.faClassInactiveCustomIcons = this.$target[0].dataset.inactiveCustomIcon || ''; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Displays the selected icon type. + * + * @see this.selectClass for parameters + */ + setIcons: function (previewMode, widgetValue, params) { + this.iconType = widgetValue; + this._renderIcons(); + this.$target[0].dataset.icon = widgetValue; + delete this.$target[0].dataset.activeCustomIcon; + delete this.$target[0].dataset.inactiveCustomIcon; + }, + /** + * Allows to select a font awesome icon with media dialog. + * + * @see this.selectClass for parameters + */ + customIcon: async function (previewMode, widgetValue, params) { + return new Promise(resolve => { + const dialog = new weWidgets.MediaDialog( + this, + {noImages: true, noDocuments: true, noVideos: true, mediaWidth: 1920}, + $('<i/>') + ); + this._saving = false; + dialog.on('save', this, function (attachments) { + this._saving = true; + const customClass = 'fa ' + attachments.className; + const $activeIcons = this.$target.find('.s_rating_active_icons > i'); + const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i'); + const $icons = params.customActiveIcon === 'true' ? $activeIcons : $inactiveIcons; + $icons.removeClass().addClass(customClass); + this.faClassActiveCustomIcons = $activeIcons.length > 0 ? $activeIcons.attr('class') : customClass; + this.faClassInactiveCustomIcons = $inactiveIcons.length > 0 ? $inactiveIcons.attr('class') : customClass; + this.$target[0].dataset.activeCustomIcon = this.faClassActiveCustomIcons; + this.$target[0].dataset.inactiveCustomIcon = this.faClassInactiveCustomIcons; + this.$target[0].dataset.icon = 'custom'; + this.iconType = 'custom'; + resolve(); + }); + dialog.on('closed', this, function () { + if (!this._saving) { + resolve(); + } + }); + dialog.open(); + }); + }, + /** + * Sets the number of active icons. + * + * @see this.selectClass for parameters + */ + activeIconsNumber: function (previewMode, widgetValue, params) { + this.nbActiveIcons = parseInt(widgetValue); + this._createIcons(); + }, + /** + * Sets the total number of icons. + * + * @see this.selectClass for parameters + */ + totalIconsNumber: function (previewMode, widgetValue, params) { + this.nbTotalIcons = Math.max(parseInt(widgetValue), 1); + this._createIcons(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'setIcons': { + return this.$target[0].dataset.icon; + } + case 'activeIconsNumber': { + this.nbActiveIcons = this.$target.find('.s_rating_active_icons > i').length; + return this.nbActiveIcons; + } + case 'totalIconsNumber': { + this.nbTotalIcons = this.$target.find('.s_rating_icons i').length; + return this.nbTotalIcons; + } + } + return this._super(...arguments); + }, + /** + * Creates the icons. + * + * @private + */ + _createIcons: function () { + const $activeIcons = this.$target.find('.s_rating_active_icons'); + const $inactiveIcons = this.$target.find('.s_rating_inactive_icons'); + this.$target.find('.s_rating_icons i').remove(); + for (let i = 0; i < this.nbTotalIcons; i++) { + if (i < this.nbActiveIcons) { + $activeIcons.append('<i/> '); + } else { + $inactiveIcons.append('<i/> '); + } + } + this._renderIcons(); + }, + /** + * Renders icons with selected fonts. + * + * @private + */ + _renderIcons: function () { + const icons = { + 'fa-star': 'fa-star-o', + 'fa-thumbs-up': 'fa-thumbs-o-up', + 'fa-circle': 'fa-circle-o', + 'fa-square': 'fa-square-o', + 'fa-heart': 'fa-heart-o' + }; + const faClassActiveIcons = (this.iconType === "custom") ? this.faClassActiveCustomIcons : 'fa ' + this.iconType; + const faClassInactiveIcons = (this.iconType === "custom") ? this.faClassInactiveCustomIcons : 'fa ' + icons[this.iconType]; + const $activeIcons = this.$target.find('.s_rating_active_icons > i'); + const $inactiveIcons = this.$target.find('.s_rating_inactive_icons > i'); + $activeIcons.removeClass().addClass(faClassActiveIcons); + $inactiveIcons.removeClass().addClass(faClassInactiveIcons); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_references/000.scss b/addons/website/static/src/snippets/s_references/000.scss new file mode 100644 index 00000000..c4a00320 --- /dev/null +++ b/addons/website/static/src/snippets/s_references/000.scss @@ -0,0 +1,4 @@ + +.s_references .img-thumbnail { + border: none; +} diff --git a/addons/website/static/src/snippets/s_share/000.js b/addons/website/static/src/snippets/s_share/000.js new file mode 100644 index 00000000..b6d630b1 --- /dev/null +++ b/addons/website/static/src/snippets/s_share/000.js @@ -0,0 +1,49 @@ +odoo.define('website.s_share', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +const ShareWidget = publicWidget.Widget.extend({ + selector: '.s_share, .oe_share', // oe_share for compatibility + + /** + * @override + */ + start: function () { + var urlRegex = /(\?(?:|.*&)(?:u|url|body)=)(.*?)(&|#|$)/; + var titleRegex = /(\?(?:|.*&)(?:title|text|subject)=)(.*?)(&|#|$)/; + var url = encodeURIComponent(window.location.href); + var title = encodeURIComponent($('title').text()); + this.$('a').each(function () { + var $a = $(this); + $a.attr('href', function (i, href) { + return href.replace(urlRegex, function (match, a, b, c) { + return a + url + c; + }).replace(titleRegex, function (match, a, b, c) { + if ($a.hasClass('s_share_whatsapp')) { + // WhatsApp does not support the "url" GET parameter. + // Instead we need to include the url within the passed "text" parameter, merging everything together. + // e.g of output: + // https://wa.me/?text=%20OpenWood%20Collection%20Online%20Reveal%20%7C%20My%20Website%20http%3A%2F%2Flocalhost%3A8888%2Fevent%2Fopenwood-collection-online-reveal-2021-06-21-2021-06-23-8%2Fregister + // see https://faq.whatsapp.com/general/chats/how-to-use-click-to-chat/ for more details + return a + title + url + c; + } + return a + title + c; + }); + }); + if ($a.attr('target') && $a.attr('target').match(/_blank/i) && !$a.closest('.o_editable').length) { + $a.on('click', function () { + window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=550,width=600'); + return false; + }); + } + }); + + return this._super.apply(this, arguments); + }, +}); + +publicWidget.registry.share = ShareWidget; + +return ShareWidget; +}); diff --git a/addons/website/static/src/snippets/s_share/000.scss b/addons/website/static/src/snippets/s_share/000.scss new file mode 100644 index 00000000..85ab9edd --- /dev/null +++ b/addons/website/static/src/snippets/s_share/000.scss @@ -0,0 +1,65 @@ + +.s_share { + > * { + display: inline-block; + vertical-align: middle; + } + .s_share_title { + margin: 0 .4rem 0 0; + } + a { + i.fa { + display: flex; + justify-content: center; + align-items: center; + } + margin: .2rem; + } + &:not(.no_icon_color) { + .s_share_facebook { + &, &:hover, &:focus { + @extend .text-facebook; + } + } + .s_share_twitter { + &, &:hover, &:focus { + @extend .text-twitter; + } + } + .s_share_linkedin { + &, &:hover, &:focus { + @extend .text-linkedin; + } + } + .s_share_google { + &, &:hover, &:focus { + @extend .text-google-plus; + } + } + .s_share_whatsapp { + &, &:hover, &:focus { + @extend .text-whatsapp; + } + } + .s_share_pinterest { + &, &:hover, &:focus { + @extend .text-pinterest; + } + } + .s_share_github { + &, &:hover, &:focus { + @extend .text-github; + } + } + .s_share_instagram { + &, &:hover, &:focus { + @extend .text-instagram; + } + } + .s_share_youtube { + &, &:hover, &:focus { + @extend .text-youtube; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_showcase/000.scss b/addons/website/static/src/snippets/s_showcase/000.scss new file mode 100644 index 00000000..8970e7e2 --- /dev/null +++ b/addons/website/static/src/snippets/s_showcase/000.scss @@ -0,0 +1,77 @@ + +#wrapwrap .s_showcase:not([data-vcss]) { + @include media-breakpoint-up(lg) { + .container, .container-fluid { + position: relative; + + &:before { + content: " "; + display: block; + @include o-position-absolute($left: 50%); + height: 100%; + border-right: 1px solid gray('200'); + } + } + } + + .fa { + opacity: 0.5; + } + + .text-right{ + .fa { + float: right; + margin-left: .5em; + } + p { + float: right; + display: block; + } + } + .text-left{ + .fa { + float: left; + margin-right: .5em; + } + p { + float: left; + } + } + .row { + margin-top: 1em; + } + .feature p { + max-width: 300px; + margin-top: 0.6em; + clear: both; + } +} + +@include media-breakpoint-down(md) { + #wrapwrap .s_showcase:not([data-vcss]) { + .text-right, .text-left { + text-align: center; + + .fa { + font-size: 2em; + opacity: 0.5; + float: none; + display: block; + position: relative; + margin-left: auto; + margin-right: auto; + } + } + .feature { + margin-bottom: 3em; + + p { + float: none; + display: block; + position: relative; + margin-left: auto; + margin-right: auto; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_showcase/001.scss b/addons/website/static/src/snippets/s_showcase/001.scss new file mode 100644 index 00000000..f7a8cd3f --- /dev/null +++ b/addons/website/static/src/snippets/s_showcase/001.scss @@ -0,0 +1,46 @@ +.s_showcase[data-vcss='001'] { + @include media-breakpoint-up(lg) { + // Left-right separator + .container, .container-fluid { + position: relative; + + &:before { + content: " "; + display: block; + @include o-position-absolute($left: 50%); + height: 100%; + border-right: 1px solid gray('200'); + } + } + // Features + .row > div { + // Items on left + &:nth-child(odd) { + text-align: right; + + .s_showcase_icon, p { + float: right; + } + + .s_showcase_icon { + margin-right: 0; + margin-left: 15px; + } + } + // Items on right + &:nth-child(even) { + text-align: left; + + .s_showcase_icon, p { + float: left; + } + } + } + } + + .s_showcase_icon { + // Make the default margin the one for the left aligned icon, as it's what we want on mobile + margin-right: 15px; + font-size: 36px; + } +} diff --git a/addons/website/static/src/snippets/s_showcase/options.js b/addons/website/static/src/snippets/s_showcase/options.js new file mode 100644 index 00000000..b870393d --- /dev/null +++ b/addons/website/static/src/snippets/s_showcase/options.js @@ -0,0 +1,19 @@ +odoo.define('website.s_showcase_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.Showcase = options.Class.extend({ + /** + * @override + */ + onMove: function () { + const $showcaseCol = this.$target.parent().closest('.row > div'); + const isLeftCol = $showcaseCol.index() <= 0; + const $title = this.$target.children('.s_showcase_title'); + $title.toggleClass('flex-lg-row-reverse', isLeftCol); + $showcaseCol.find('.s_showcase_icon.ml-3').removeClass('ml-3').addClass('ml-lg-3'); // For compatibility with old version + $title.find('.s_showcase_icon').toggleClass('mr-lg-0 ml-lg-3', isLeftCol); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_table_of_content/000.js b/addons/website/static/src/snippets/s_table_of_content/000.js new file mode 100644 index 00000000..fcae4ed7 --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/000.js @@ -0,0 +1,77 @@ +odoo.define('website.s_table_of_content', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); +const {extraMenuUpdateCallbacks} = require('website.content.menu'); + +const TableOfContent = publicWidget.Widget.extend({ + selector: 'section .s_table_of_content_navbar_sticky', + disabledInEditableMode: false, + + /** + * @override + */ + async start() { + await this._super(...arguments); + this._updateTableOfContentNavbarPosition(); + extraMenuUpdateCallbacks.push(this._updateTableOfContentNavbarPosition.bind(this)); + }, + /** + * @override + */ + destroy() { + this.$target.css('top', ''); + this.$target.find('.s_table_of_content_navbar').css('top', ''); + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateTableOfContentNavbarPosition() { + let position = 0; + const $fixedElements = $('.o_top_fixed_element'); + _.each($fixedElements, el => position += $(el).outerHeight()); + const isHorizontalNavbar = this.$target.hasClass('s_table_of_content_horizontal_navbar'); + this.$target.css('top', isHorizontalNavbar ? position : ''); + this.$target.find('.s_table_of_content_navbar').css('top', isHorizontalNavbar ? '' : position + 20); + const $mainNavBar = $('#oe_main_menu_navbar'); + position += $mainNavBar.length ? $mainNavBar.outerHeight() : 0; + position += isHorizontalNavbar ? this.$target.outerHeight() : 0; + $().getScrollingElement().scrollspy({target: '.s_table_of_content_navbar', method: 'offset', offset: position + 100, alwaysKeepFirstActive: true}); + }, +}); + +publicWidget.registry.anchorSlide.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overridden to add the height of the horizontal sticky navbar at the scroll value + * when the link is from the table of content navbar + * + * @override + * @private + */ + _computeExtraOffset() { + let extraOffset = this._super(...arguments); + if (this.$el.hasClass('table_of_content_link')) { + const tableOfContentNavbarEl = this.$el.closest('.s_table_of_content_navbar_sticky.s_table_of_content_horizontal_navbar'); + if (tableOfContentNavbarEl.length > 0) { + extraOffset += $(tableOfContentNavbarEl).outerHeight(); + } + } + return extraOffset; + }, +}); + +publicWidget.registry.snippetTableOfContent = TableOfContent; + +return TableOfContent; +}); diff --git a/addons/website/static/src/snippets/s_table_of_content/000.scss b/addons/website/static/src/snippets/s_table_of_content/000.scss new file mode 100644 index 00000000..670e1365 --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/000.scss @@ -0,0 +1,65 @@ +.s_table_of_content:not([data-vcss]) { + .s_table_of_content_navbar_wrap { + &.s_table_of_content_navbar_sticky { + &.s_table_of_content_horizontal_navbar, &.s_table_of_content_vertical_navbar .s_table_of_content_navbar { + @include o-position-sticky($top: 0px); + } + } + &:not(.s_table_of_content_navbar_sticky) { + &, .s_table_of_content_navbar { + top: 0px !important; + } + } + &.s_table_of_content_vertical_navbar .s_table_of_content_navbar { + > a.list-group-item-action { + background: none; + color: inherit; + opacity: 0.7; + font-weight: $font-weight-normal + 100; + padding-left: 3px; + transition: padding 0.1s; + + &:before { + @include o-position-absolute(10px, auto, 10px, 0); + width: 2px; + content: ""; + } + &:hover { + opacity: 1; + } + &:focus { + background: none; + } + &.active { + background: none; + padding-left: 8px; + opacity: 1; + + &:before { + background-color: theme-color('primary'); + } + } + } + } + &.s_table_of_content_horizontal_navbar { + z-index: 1; + padding-top: $navbar-padding-y; + padding-bottom: $navbar-padding-y; + margin-bottom: $spacer * 2; + + .s_table_of_content_navbar { + display: inline; + + > a { + &.list-group-item-action { + width: auto; + } + &.list-group-item { + display: inline-block; + margin-bottom: 2px; + } + } + } + } + } +} diff --git a/addons/website/static/src/snippets/s_table_of_content/options.js b/addons/website/static/src/snippets/s_table_of_content/options.js new file mode 100644 index 00000000..3feb0c74 --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/options.js @@ -0,0 +1,122 @@ +odoo.define('website.s_table_of_content_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.TableOfContent = options.Class.extend({ + /** + * @override + */ + start: function () { + this.targetedElements = 'h1, h2'; + const $headings = this.$target.find(this.targetedElements); + if ($headings.length > 0) { + this._generateNav(); + } + // Generate the navbar if the content changes + const targetNode = this.$target.find('.s_table_of_content_main')[0]; + const config = {attributes: false, childList: true, subtree: true, characterData: true}; + this.observer = new MutationObserver(() => this._generateNav()); + this.observer.observe(targetNode, config); + return this._super(...arguments); + }, + /** + * @override + */ + onClone: function () { + this._generateNav(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _generateNav: function (ev) { + const $nav = this.$target.find('.s_table_of_content_navbar'); + const $headings = this.$target.find(this.targetedElements); + $nav.empty(); + _.each($headings, el => { + const $el = $(el); + const id = 'table_of_content_heading_' + _.now() + '_' + _.uniqueId(); + $('<a>').attr('href', "#" + id) + .addClass('table_of_content_link list-group-item list-group-item-action py-2 border-0 rounded-0') + .text($el.text()) + .appendTo($nav); + $el.attr('id', id); + $el[0].dataset.anchor = 'true'; + }); + $nav.find('a:first').addClass('active'); + }, +}); + +options.registry.TableOfContentNavbar = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change the navbar position. + * + * @see this.selectClass for parameters + */ + navbarPosition: function (previewMode, widgetValue, params) { + const $navbar = this.$target; + const $mainContent = this.$target.parent().find('.s_table_of_content_main'); + if (widgetValue === 'top' || widgetValue === 'left') { + $navbar.prev().before($navbar); + } + if (widgetValue === 'left' || widgetValue === 'right') { + $navbar.removeClass('s_table_of_content_horizontal_navbar col-lg-12').addClass('s_table_of_content_vertical_navbar col-lg-3'); + $mainContent.removeClass('col-lg-12').addClass('col-lg-9'); + $navbar.find('.s_table_of_content_navbar').removeClass('list-group-horizontal-md'); + } + if (widgetValue === 'right') { + $navbar.next().after($navbar); + } + if (widgetValue === 'top') { + $navbar.removeClass('s_table_of_content_vertical_navbar col-lg-3').addClass('s_table_of_content_horizontal_navbar col-lg-12'); + $navbar.find('.s_table_of_content_navbar').addClass('list-group-horizontal-md'); + $mainContent.removeClass('col-lg-9').addClass('col-lg-12'); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'navbarPosition': { + const $navbar = this.$target; + if ($navbar.hasClass('s_table_of_content_horizontal_navbar')) { + return 'top'; + } else { + const $mainContent = $navbar.parent().find('.s_table_of_content_main'); + return $navbar.prev().is($mainContent) === true ? 'right' : 'left'; + } + } + } + return this._super(...arguments); + }, +}); + +options.registry.TableOfContentMainColumns = options.Class.extend({ + forceNoDeleteButton: true, + + /** + * @override + */ + start: function () { + const leftPanelEl = this.$overlay.data('$optionsSection')[0]; + leftPanelEl.querySelector('.oe_snippet_clone').classList.add('d-none'); // TODO improve the way to do that + return this._super.apply(this, arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_tabs/001.scss b/addons/website/static/src/snippets/s_tabs/001.scss new file mode 100644 index 00000000..6e132e91 --- /dev/null +++ b/addons/website/static/src/snippets/s_tabs/001.scss @@ -0,0 +1,25 @@ +// Tabs +.s_tabs[data-vcss="001"] { + .s_tabs_content { + &.s_tabs_slide_up, &.s_tabs_slide_down, &.s_tabs_slide_left, &.s_tabs_slide_right { + > .tab-pane.fade { + transition: all 0.2s; + } + > .tab-pane.fade.show { + transform: translateX(0rem) translateY(0rem); + } + } + &.s_tabs_slide_up > .tab-pane.fade { + transform: translateY(-1rem); + } + &.s_tabs_slide_down > .tab-pane.fade { + transform: translateY(1rem); + } + &.s_tabs_slide_left > .tab-pane.fade { + transform: translateX(-1rem); + } + &.s_tabs_slide_right > .tab-pane.fade { + transform: translateX(1rem); + } + } +} diff --git a/addons/website/static/src/snippets/s_tabs/options.js b/addons/website/static/src/snippets/s_tabs/options.js new file mode 100644 index 00000000..b10a7619 --- /dev/null +++ b/addons/website/static/src/snippets/s_tabs/options.js @@ -0,0 +1,167 @@ +odoo.define('website.s_tabs_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.NavTabs = options.Class.extend({ + isTopOption: true, + + /** + * @override + */ + start: function () { + this._findLinksAndPanes(); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._generateUniqueIDs(); + }, + /** + * @override + */ + onClone: function () { + this._generateUniqueIDs(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Creates a new tab and tab-pane. + * + * @see this.selectClass for parameters + */ + addTab: function (previewMode, widgetValue, params) { + var $activeItem = this.$navLinks.filter('.active').parent(); + var $activePane = this.$tabPanes.filter('.active'); + + var $navItem = $activeItem.clone(); + var $navLink = $navItem.find('.nav-link').removeClass('active show'); + var $tabPane = $activePane.clone().removeClass('active show'); + $navItem.insertAfter($activeItem); + $tabPane.insertAfter($activePane); + this._findLinksAndPanes(); + this._generateUniqueIDs(); + + $navLink.tab('show'); + }, + /** + * Removes the current active tab and its content. + * + * @see this.selectClass for parameters + */ + removeTab: function (previewMode, widgetValue, params) { + var self = this; + + var $activeLink = this.$navLinks.filter('.active'); + var $activePane = this.$tabPanes.filter('.active'); + + var $next = this.$navLinks.eq((this.$navLinks.index($activeLink) + 1) % this.$navLinks.length); + + return new Promise(resolve => { + $next.one('shown.bs.tab', function () { + $activeLink.parent().remove(); + $activePane.remove(); + self._findLinksAndPanes(); + resolve(); + }); + $next.tab('show'); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetVisibility: async function (widgetName, params) { + if (widgetName === 'remove_tab_opt') { + return (this.$tabPanes.length > 2); + } + return this._super(...arguments); + }, + /** + * @private + */ + _findLinksAndPanes: function () { + this.$navLinks = this.$target.find('.nav:first .nav-link'); + this.$tabPanes = this.$target.find('.tab-content:first .tab-pane'); + }, + /** + * @private + */ + _generateUniqueIDs: function () { + for (var i = 0; i < this.$navLinks.length; i++) { + var id = _.now() + '_' + _.uniqueId(); + var idLink = 'nav_tabs_link_' + id; + var idContent = 'nav_tabs_content_' + id; + this.$navLinks.eq(i).attr({ + 'id': idLink, + 'href': '#' + idContent, + 'aria-controls': idContent, + }); + this.$tabPanes.eq(i).attr({ + 'id': idContent, + 'aria-labelledby': idLink, + }); + } + }, +}); +options.registry.NavTabsStyle = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Set the style of the tabs. + * + * @see this.selectClass for parameters + */ + setStyle: function (previewMode, widgetValue, params) { + const $nav = this.$target.find('.s_tabs_nav:first .nav'); + const isPills = widgetValue === 'pills'; + $nav.toggleClass('nav-tabs card-header-tabs', !isPills); + $nav.toggleClass('nav-pills', isPills); + this.$target.find('.s_tabs_nav:first').toggleClass('card-header', !isPills).toggleClass('mb-3', isPills); + this.$target.toggleClass('card', !isPills); + this.$target.find('.s_tabs_content:first').toggleClass('card-body', !isPills); + }, + /** + * Horizontal/vertical nav. + * + * @see this.selectClass for parameters + */ + setDirection: function (previewMode, widgetValue, params) { + const isVertical = widgetValue === 'vertical'; + this.$target.toggleClass('row s_col_no_resize s_col_no_bgcolor', isVertical); + this.$target.find('.s_tabs_nav:first .nav').toggleClass('flex-column', isVertical); + this.$target.find('.s_tabs_nav:first > .nav-link').toggleClass('py-2', isVertical); + this.$target.find('.s_tabs_nav:first').toggleClass('col-md-3', isVertical); + this.$target.find('.s_tabs_content:first').toggleClass('col-md-9', isVertical); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'setStyle': + return this.$target.find('.s_tabs_nav:first .nav').hasClass('nav-pills') ? 'pills' : 'tabs'; + case 'setDirection': + return this.$target.find('.s_tabs_nav:first .nav').hasClass('flex-column') ? 'vertical' : 'horizontal'; + } + return this._super(...arguments); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_text_highlight/000.scss b/addons/website/static/src/snippets/s_text_highlight/000.scss new file mode 100644 index 00000000..913f04bb --- /dev/null +++ b/addons/website/static/src/snippets/s_text_highlight/000.scss @@ -0,0 +1,7 @@ +.s_text_highlight { + padding: 1.5rem; + border-radius: $border-radius; + :last-child { + margin-bottom: 0; + } +} diff --git a/addons/website/static/src/snippets/s_three_columns/000.scss b/addons/website/static/src/snippets/s_three_columns/000.scss new file mode 100644 index 00000000..24e5224e --- /dev/null +++ b/addons/website/static/src/snippets/s_three_columns/000.scss @@ -0,0 +1,5 @@ +.s_three_columns:not([data-vcss]) { + .align-items-stretch > .card { + height: 100%; + } +} diff --git a/addons/website/static/src/snippets/s_timeline/000.scss b/addons/website/static/src/snippets/s_timeline/000.scss new file mode 100644 index 00000000..4a0ce4df --- /dev/null +++ b/addons/website/static/src/snippets/s_timeline/000.scss @@ -0,0 +1,70 @@ +.s_timeline { + .s_timeline_line { + position: relative; + &:before { + content: ''; + display: block !important; // override portal '#wrap .container' value + position: absolute; + width: 1px; + top: 0px; + bottom: 0px; + left: 50%; + background-color: gray('800'); + } + } + .s_timeline_row { + align-items: center; + .s_timeline_content { + align-items: center; + justify-content: flex-end; + width: 100%; + ~ .s_timeline_content { + justify-content: flex-start; + } + } + &.flex-row-reverse { + .s_timeline_content { + flex-direction: row-reverse; + } + } + @include media-breakpoint-up(md) { + &.flex-row-reverse { + .s_timeline_content { + flex-direction: row-reverse; + &:not(:last-child) { + margin-left: 10%; + } + } + } + &:not(.flex-row-reverse) { + .s_timeline_content:last-child { + margin-left: 10%; + } + } + } + } + .s_timeline_date { + @include media-breakpoint-up(md) { + position: absolute; + left: 0%; + right: 0%; + } + @include media-breakpoint-down(sm) { + position: relative; + margin: 20px 0px; + } + span:not(.fa) { + display: inline-block; + padding: 5px; + } + .fa { + margin: 0 $grid-gutter-width/2; + } + text-align: center; + } + .s_timeline_icon { + flex: 0 0 auto; + margin: $grid-gutter-width/2; + z-index: 1; + } +} diff --git a/addons/website/static/src/snippets/s_timeline/options.js b/addons/website/static/src/snippets/s_timeline/options.js new file mode 100644 index 00000000..c1efa23c --- /dev/null +++ b/addons/website/static/src/snippets/s_timeline/options.js @@ -0,0 +1,32 @@ +odoo.define('website.s_timeline_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.Timeline = options.Class.extend({ + /** + * @override + */ + start: function () { + var $buttons = this.$el.find('we-button'); + var $overlayArea = this.$overlay.find('.o_overlay_options_wrap'); + $overlayArea.append($('<div/>').append($buttons)); + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the card to the right/left. + * + * @see this.selectClass for parameters + */ + timelineCard: function (previewMode, widgetValue, params) { + const $timelineRow = this.$target.closest('.s_timeline_row'); + $timelineRow.toggleClass('flex-row-reverse flex-row'); + }, +}); +}); diff --git a/addons/website/static/src/snippets/s_title/000.scss b/addons/website/static/src/snippets/s_title/000.scss new file mode 100644 index 00000000..a4e76dca --- /dev/null +++ b/addons/website/static/src/snippets/s_title/000.scss @@ -0,0 +1,36 @@ + +.s_title:not([data-vcss]) { + .s_title_boxed { + > * { + display: inline-block; + padding: $grid-gutter-width; + border: 1px solid; + } + } + .s_title_lines { + overflow: hidden; + &:before, + &:after { + content: ""; + display: inline-block; + vertical-align: middle; + width: 100%; + border-top: 1px solid; + border-top-color: inherit; + } + &:before { margin: 0 $grid-gutter-width/2 0 -100%; } + &:after { margin: 0 -100% 0 $grid-gutter-width/2; } + } + .s_title_underlined { + @extend %o-page-header; + } + .s_title_small_caps { + font-variant: small-caps; + } + .s_title_transparent { + opacity: .5; + } + .s_title_thin { + font-weight: 300; + } +} |
