diff options
| author | qsm-odoo <qsm@odoo.com> | 2019-12-18 02:05:34 +0100 |
|---|---|---|
| committer | qsm-odoo <qsm@odoo.com> | 2019-12-18 02:14:51 +0100 |
| commit | bb25b70a749bcfd6f84cb62936612e311fe6fe85 (patch) | |
| tree | 1e0369470e1ed33f0ba89d2351f9494146866a22 /addons/website | |
| parent | 885a015b8af83ab2674034af42f201f97ec45f5b (diff) | |
| parent | b0c220536c0faf2e7f9279eedb776bf3b7f18363 (diff) | |
[REF] website: move s_image_gallery snippet to dedicated files
Diffstat (limited to 'addons/website')
| -rw-r--r-- | addons/website/__manifest__.py | 1 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/snippets.animation.js | 1467 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/snippets.options.js | 1888 | ||||
| -rw-r--r-- | addons/website/static/src/scss/website.scss | 921 | ||||
| -rw-r--r-- | addons/website/static/src/snippets/s_image_gallery/000.js | 172 | ||||
| -rw-r--r-- | addons/website/static/src/snippets/s_image_gallery/000.scss | 156 | ||||
| -rw-r--r-- | addons/website/static/src/snippets/s_image_gallery/options.js | 456 | ||||
| -rw-r--r-- | addons/website/views/assets.xml | 1 | ||||
| -rw-r--r-- | addons/website/views/snippets/s_image_gallery.xml | 78 | ||||
| -rw-r--r-- | addons/website/views/snippets/snippets.xml | 1071 |
10 files changed, 6211 insertions, 0 deletions
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 51d2dee023d..a9e01a1c51b 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -44,6 +44,7 @@ 'views/snippets/s_btn.xml', 'views/snippets/s_hr.xml', 'views/snippets/s_facebook_page.xml', + 'views/snippets/s_image_gallery.xml', 'views/website_views.xml', 'views/website_visitor_views.xml', 'views/res_config_settings_views.xml', diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js new file mode 100644 index 00000000000..8dcd9809c31 --- /dev/null +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -0,0 +1,1467 @@ +odoo.define('website.content.snippets.animation', function (require) { +'use strict'; + +/** + * Provides a way to start JS code for snippets' initialization and animations. + */ + +var Class = require('web.Class'); +const ColorpickerDialog = require('web.ColorpickerDialog'); +var config = require('web.config'); +var core = require('web.core'); +var mixins = require('web.mixins'); +var publicWidget = require('web.public.widget'); +var utils = require('web.utils'); + +var qweb = core.qweb; +const _t = core._t; + +// Initialize fallbacks for the use of requestAnimationFrame, +// cancelAnimationFrame and performance.now() +window.requestAnimationFrame = window.requestAnimationFrame + || window.webkitRequestAnimationFrame + || window.mozRequestAnimationFrame + || window.msRequestAnimationFrame + || window.oRequestAnimationFrame; +window.cancelAnimationFrame = window.cancelAnimationFrame + || window.webkitCancelAnimationFrame + || window.mozCancelAnimationFrame + || window.msCancelAnimationFrame + || window.oCancelAnimationFrame; +if (!window.performance || !window.performance.now) { + window.performance = { + now: function () { + return Date.now(); + } + }; +} + +/** + * Add the notion of edit mode to public widgets. + */ +publicWidget.Widget.include({ + /** + * Indicates if the widget should not be instantiated in edit. The default + * is true, indeed most (all?) defined widgets only want to initialize + * events and states which should not be active in edit mode (this is + * especially true for non-website widgets). + * + * @type {boolean} + */ + disabledInEditableMode: true, + /** + * Acts as @see Widget.events except that the events are only binded if the + * Widget instance is instanciated in edit mode. The property is not + * considered if @see disabledInEditableMode is false. + */ + edit_events: null, + /** + * Acts as @see Widget.events except that the events are only binded if the + * Widget instance is instanciated in readonly mode. The property only + * makes sense if @see disabledInEditableMode is false, you should simply + * use @see Widget.events otherwise. + */ + read_events: null, + + /** + * Initializes the events that will need to be binded according to the + * given mode. + * + * @constructor + * @param {Object} parent + * @param {Object} [options] + * @param {boolean} [options.editableMode=false] + * true if the page is in edition mode + */ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.editableMode = this.options.editableMode || false; + var extraEvents = this.editableMode ? this.edit_events : this.read_events; + if (extraEvents) { + this.events = _.extend({}, this.events || {}, extraEvents); + } + }, +}); + +/** + * In charge of handling one animation loop using the requestAnimationFrame + * feature. This is used by the `Animation` class below and should not be called + * directly by an end developer. + * + * This uses a simple API: it can be started, stopped, played and paused. + */ +var AnimationEffect = Class.extend(mixins.ParentedMixin, { + /** + * @constructor + * @param {Object} parent + * @param {function} updateCallback - the animation update callback + * @param {string} [startEvents=scroll] + * space separated list of events which starts the animation loop + * @param {jQuery|DOMElement} [$startTarget=window] + * the element(s) on which the startEvents are listened + * @param {Object} [options] + * @param {function} [options.getStateCallback] + * a function which returns a value which represents the state of the + * animation, i.e. for two same value, no refreshing of the animation + * is needed. Can be used for optimization. If the $startTarget is + * the window element, this defaults to returning the current + * scoll offset of the window or the size of the window for the + * scroll and resize events respectively. + * @param {string} [options.endEvents] + * space separated list of events which pause the animation loop. If + * not given, the animation is stopped after a while (if no + * startEvents is received again) + * @param {jQuery|DOMElement} [options.$endTarget=$startTarget] + * the element(s) on which the endEvents are listened + */ + init: function (parent, updateCallback, startEvents, $startTarget, options) { + mixins.ParentedMixin.init.call(this); + this.setParent(parent); + + options = options || {}; + this._minFrameTime = 1000 / (options.maxFPS || 100); + + // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks + this._updateCallback = updateCallback; + this.startEvents = startEvents || 'scroll'; + this.$startTarget = $($startTarget || window); + if (options.getStateCallback) { + this._getStateCallback = options.getStateCallback; + } else if (this.startEvents === 'scroll' && this.$startTarget[0] === window) { + this._getStateCallback = function () { + return window.pageYOffset; + }; + } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) { + this._getStateCallback = function () { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }; + } else { + this._getStateCallback = function () { + return undefined; + }; + } + this.endEvents = options.endEvents || false; + this.$endTarget = options.$endTarget ? $(options.$endTarget) : this.$startTarget; + + this._updateCallback = this._updateCallback.bind(parent); + this._getStateCallback = this._getStateCallback.bind(parent); + + // Add a namespace to events using the generated uid + this._uid = '_animationEffect' + _.uniqueId(); + this.startEvents = _processEvents(this.startEvents, this._uid); + if (this.endEvents) { + this.endEvents = _processEvents(this.endEvents, this._uid); + } + + function _processEvents(events, namespace) { + events = events.split(' '); + return _.each(events, function (e, index) { + events[index] += ('.' + namespace); + }).join(' '); + } + }, + /** + * @override + */ + destroy: function () { + mixins.ParentedMixin.destroy.call(this); + this.stop(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Initializes when the animation must be played and paused and initializes + * the animation first frame. + */ + start: function () { + // Initialize the animation first frame + this._paused = false; + this._rafID = window.requestAnimationFrame((function (t) { + this._update(t); + this._paused = true; + }).bind(this)); + + // Initialize the animation play/pause events + if (this.endEvents) { + /** + * If there are endEvents, the animation should begin playing when + * the startEvents are triggered on the $startTarget and pause when + * the endEvents are triggered on the $endTarget. + */ + this.$startTarget.on(this.startEvents, (function (e) { + if (this._paused) { + _.defer(this.play.bind(this, e)); + } + }).bind(this)); + this.$endTarget.on(this.endEvents, (function () { + if (!this._paused) { + _.defer(this.pause.bind(this)); + } + }).bind(this)); + } else { + /** + * Else, if there is no endEvents, the animation should begin playing + * when the startEvents are *continuously* triggered on the + * $startTarget or fully played once. To achieve this, the animation + * begins playing and is scheduled to pause after 2 seconds. If the + * startEvents are triggered during that time, this is not paused + * for another 2 seconds. This allows to describe an "effect" + * animation (which lasts less than 2 seconds) or an animation which + * must be playing *during* an event (scroll, mousemove, resize, + * repeated clicks, ...). + */ + var pauseTimer = null; + this.$startTarget.on(this.startEvents, _.throttle((function (e) { + this.play(e); + + clearTimeout(pauseTimer); + pauseTimer = _.delay((function () { + this.pause(); + pauseTimer = null; + }).bind(this), 2000); + }).bind(this), 250, {trailing: false})); + } + }, + /** + * Pauses the animation and destroys the attached events which trigger the + * animation to be played or paused. + */ + stop: function () { + this.$startTarget.off(this.startEvents); + if (this.endEvents) { + this.$endTarget.off(this.endEvents); + } + this.pause(); + }, + /** + * Forces the requestAnimationFrame loop to start. + * + * @param {Event} e - the event which triggered the animation to play + */ + play: function (e) { + this._newEvent = e; + if (!this._paused) { + return; + } + this._paused = false; + this._rafID = window.requestAnimationFrame(this._update.bind(this)); + this._lastUpdateTimestamp = undefined; + }, + /** + * Forces the requestAnimationFrame loop to stop. + */ + pause: function () { + if (this._paused) { + return; + } + this._paused = true; + window.cancelAnimationFrame(this._rafID); + this._lastUpdateTimestamp = undefined; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Callback which is repeatedly called by the requestAnimationFrame loop. + * It controls the max fps at which the animation is running and initializes + * the values that the update callback needs to describe the animation + * (state, elapsedTime, triggered event). + * + * @private + * @param {DOMHighResTimeStamp} timestamp + */ + _update: function (timestamp) { + if (this._paused) { + return; + } + this._rafID = window.requestAnimationFrame(this._update.bind(this)); + + // Check the elapsed time since the last update callback call. + // Consider it 0 if there is no info of last timestamp and leave this + // _update call if it was called too soon (would overflow the set max FPS). + var elapsedTime = 0; + if (this._lastUpdateTimestamp) { + elapsedTime = timestamp - this._lastUpdateTimestamp; + if (elapsedTime < this._minFrameTime) { + return; + } + } + + // Check the new animation state thanks to the get state callback and + // store its new value. If the state is the same as the previous one, + // leave this _update call, except if there is an event which triggered + // the "play" method again. + var animationState = this._getStateCallback(elapsedTime, this._newEvent); + if (!this._newEvent + && animationState !== undefined + && _.isEqual(animationState, this._animationLastState)) { + return; + } + this._animationLastState = animationState; + + // Call the update callback with frame parameters + this._updateCallback(this._animationLastState, elapsedTime, this._newEvent); + this._lastUpdateTimestamp = timestamp; // Save the timestamp at which the update callback was really called + this._newEvent = undefined; // Forget the event which triggered the last "play" call + }, +}); + +/** + * Also register AnimationEffect automatically (@see effects, _prepareEffects). + */ +var Animation = publicWidget.Widget.extend({ + /** + * The max FPS at which all the automatic animation effects will be + * running by default. + */ + maxFPS: 100, + /** + * @see this._prepareEffects + * + * @type {Object[]} + * @type {string} startEvents + * The names of the events which trigger the effect to begin playing. + * @type {string} [startTarget] + * A selector to find the target where to listen for the start events + * (if no selector, the window target will be used). If the whole + * $target of the animation should be used, use the 'selector' string. + * @type {string} [endEvents] + * The name of the events which trigger the end of the effect (if none + * is defined, the animation will stop after a while + * @see AnimationEffect.start). + * @type {string} [endTarget] + * A selector to find the target where to listen for the end events + * (if no selector, the startTarget will be used). If the whole + * $target of the animation should be used, use the 'selector' string. + * @type {string} update + * A string which refers to a method which will be used as the update + * callback for the effect. It receives 3 arguments: the animation + * state, the elapsedTime since last update and the event which + * triggered the animation (undefined if just a new update call + * without trigger). + * @type {string} [getState] + * The animation state is undefined by default, the scroll offset for + * the particular {startEvents: 'scroll'} effect and an object with + * width and height for the particular {startEvents: 'resize'} effect. + * There is the possibility to define the getState callback of the + * animation effect with this key. This allows to improve performance + * even further in some cases. + */ + effects: [], + + /** + * Initializes the animation. The method should not be called directly as + * called automatically on animation instantiation and on restart. + * + * Also, prepares animation's effects and start them if any. + * + * @override + */ + start: function () { + this._prepareEffects(); + _.each(this._animationEffects, function (effect) { + effect.start(); + }); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Registers `AnimationEffect` instances. + * + * This can be done by extending this method and calling the @see _addEffect + * method in it or, better, by filling the @see effects property. + * + * @private + */ + _prepareEffects: function () { + this._animationEffects = []; + + var self = this; + _.each(this.effects, function (desc) { + self._addEffect(self[desc.update], desc.startEvents, _findTarget(desc.startTarget), { + getStateCallback: desc.getState && self[desc.getState], + endEvents: desc.endEvents || undefined, + $endTarget: _findTarget(desc.endTarget), + maxFPS: self.maxFPS, + }); + + // Return the DOM element matching the selector in the form + // described above. + function _findTarget(selector) { + if (selector) { + if (selector === 'selector') { + return self.$target; + } + return self.$(selector); + } + return undefined; + } + }); + }, + /** + * Registers a new `AnimationEffect` according to given parameters. + * + * @private + * @see AnimationEffect.init + */ + _addEffect: function (updateCallback, startEvents, $startTarget, options) { + this._animationEffects.push( + new AnimationEffect(this, updateCallback, startEvents, $startTarget, options) + ); + }, +}); + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +var registry = publicWidget.registry; + +registry.slider = publicWidget.Widget.extend({ + selector: '.carousel', + disabledInEditableMode: false, + edit_events: { + 'content_changed': '_onContentChanged', + }, + + /** + * @override + */ + start: function () { + this.$('img').on('load.slider', () => this._computeHeights()); + this._computeHeights(); + // Initialize carousel and pause if in edit mode. + this.$target.carousel(this.editableMode ? 'pause' : undefined); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$('img').off('.slider'); + this.$target.carousel('pause'); + this.$target.removeData('bs.carousel'); + _.each(this.$('.carousel-item'), function (el) { + $(el).css('min-height', ''); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _computeHeights: function () { + var maxHeight = 0; + var $items = this.$('.carousel-item'); + $items.css('min-height', ''); + _.each($items, function (el) { + var $item = $(el); + var isActive = $item.hasClass('active'); + $item.addClass('active'); + var height = $item.outerHeight(); + if (height > maxHeight) { + maxHeight = height; + } + $item.toggleClass('active', isActive); + }); + $items.css('min-height', maxHeight); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onContentChanged: function (ev) { + this._computeHeights(); + }, +}); + +registry.parallax = Animation.extend({ + selector: '.parallax', + disabledInEditableMode: false, + effects: [{ + startEvents: 'scroll', + update: '_onWindowScroll', + }], + + /** + * @override + */ + start: function () { + this._rebuild(); + $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500)); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(window).off('.animation_parallax'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Prepares the background element which will scroll at a different speed + * according to the viewport dimensions and other snippet parameters. + * + * @private + */ + _rebuild: function () { + // Add/find bg DOM element to hold the parallax bg (support old v10.0 parallax) + if (!this.$bg || !this.$bg.length) { + this.$bg = this.$('> .s_parallax_bg'); + if (!this.$bg.length) { + this.$bg = $('<span/>', { + class: 's_parallax_bg' + (this.$target.hasClass('oe_custom_bg') ? ' oe_custom_bg' : ''), + }).prependTo(this.$target); + } + } + var urlTarget = this.$target.css('background-image'); + if (urlTarget !== 'none') { + this.$bg.css('background-image', urlTarget); + } + this.$target.css('background-image', 'none'); + + // Get parallax speed + this.speed = parseFloat(this.$target.attr('data-scroll-background-ratio') || 0); + + // Reset offset if parallax effect will not be performed and leave + this.$target.toggleClass('s_parallax_is_fixed', this.speed === 1); + var noParallaxSpeed = (this.speed === 0 || this.speed === 1); + this.$target.toggleClass('s_parallax_no_overflow_hidden', noParallaxSpeed); + if (noParallaxSpeed) { + this.$bg.css({ + transform: '', + top: '', + bottom: '', + }); + return; + } + + // Initialize parallax data according to snippet and viewport dimensions + this.viewport = document.body.clientHeight - $('#wrapwrap').position().top; + this.visibleArea = [this.$target.offset().top]; + this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport); + this.ratio = this.speed * (this.viewport / 10); + + // Provide a "safe-area" to limit parallax + this.$bg.css({ + top: -this.ratio, + bottom: -this.ratio, + }); + }, + + //-------------------------------------------------------------------------- + // Effects + //-------------------------------------------------------------------------- + + /** + * Describes how to update the snippet when the window scrolls. + * + * @private + * @param {integer} scrollOffset + */ + _onWindowScroll: function (scrollOffset) { + // Speed == 0 is no effect and speed == 1 is handled by CSS only + if (this.speed === 0 || this.speed === 1) { + return; + } + + // Perform translation if the element is visible only + var vpEndOffset = scrollOffset + this.viewport; + if (vpEndOffset >= this.visibleArea[0] + && vpEndOffset <= this.visibleArea[1]) { + this.$bg.css('transform', 'translateY(' + _getNormalizedPosition.call(this, vpEndOffset) + 'px)'); + } + + function _getNormalizedPosition(pos) { + // Normalize scroll in a 1 to 0 range + var r = (pos - this.visibleArea[1]) / (this.visibleArea[0] - this.visibleArea[1]); + // Normalize accordingly to current options + return Math.round(this.ratio * (2 * r - 1)); + } + }, +}); + +registry.mediaVideo = publicWidget.Widget.extend({ + selector: '.media_iframe_video', + + /** + * @override + */ + start: function () { + // TODO: this code should be refactored to make more sense and be better + // integrated with Odoo (this refactoring should be done in master). + + var def = this._super.apply(this, arguments); + if (this.$target.children('iframe').length) { + // There already is an <iframe/>, do nothing + return def; + } + + // Bug fix / compatibility: empty the <div/> element as all information + // to rebuild the iframe should have been saved on the <div/> element + this.$target.empty(); + + // Add extra content for size / edition + this.$target.append( + '<div class="css_editable_mode_display"> </div>' + + '<div class="media_iframe_video_size"> </div>' + ); + + // Rebuild the iframe. Depending on version / compatibility / instance, + // the src is saved in the 'data-src' attribute or the + // 'data-oe-expression' one (the latter is used as a workaround in 10.0 + // system but should obviously be reviewed in master). + this.$target.append($('<iframe/>', { + src: _.escape(this.$target.data('oe-expression') || this.$target.data('src')), + frameborder: '0', + allowfullscreen: 'allowfullscreen', + sandbox: 'allow-scripts allow-same-origin', // https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ + })); + + return def; + }, +}); + +registry.backgroundVideo = publicWidget.Widget.extend({ + selector: '.o_background_video', + xmlDependencies: ['/website/static/src/xml/website.background.video.xml'], + disabledInEditableMode: false, + + /** + * @override + */ + start: function () { + var proms = [this._super(...arguments)]; + + this.videoSrc = this.el.dataset.bgVideoSrc; + this.iframeID = _.uniqueId('o_bg_video_iframe_'); + + this.isYoutubeVideo = this.videoSrc.indexOf('youtube') >= 0; + this.isMobileEnv = config.device.size_class <= config.device.SIZES.LG && config.device.touch; + if (this.isYoutubeVideo && this.isMobileEnv) { + this.videoSrc = this.videoSrc + "&enablejsapi=1"; + + if (!window.YT) { + proms.push(new Promise(resolve => { + window.onYouTubeIframeAPIReady = () => resolve(); + })); + $('<script/>', { + src: 'https://www.youtube.com/iframe_api', + }).appendTo('head'); + } + } + + var throttledUpdate = _.throttle(() => this._adjustIframe(), 50); + + var $dropdownMenu = this.$el.closest('.dropdown-menu'); + if ($dropdownMenu.length) { + this.$dropdownParent = $dropdownMenu.parent(); + this.$dropdownParent.on('shown.bs.dropdown.backgroundVideo', throttledUpdate); + } + + $(window).on('resize.' + this.iframeID, throttledUpdate); + + return Promise.all(proms).then(() => this._appendBgVideo()); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + + if (this.$dropdownParent) { + this.$dropdownParent.off('.backgroundVideo'); + } + + $(window).off('resize.' + this.iframeID); + + if (this.$bgVideoContainer) { + this.$bgVideoContainer.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adjusts iframe sizes and position so that it fills the container and so + * that it is centered in it. + * + * @private + */ + _adjustIframe: function () { + this.$iframe.removeClass('show'); + + // Adjust the iframe + var wrapperWidth = this.$target.innerWidth(); + var wrapperHeight = this.$target.innerHeight(); + var relativeRatio = (wrapperWidth / wrapperHeight) / (16 / 9); + var style = {}; + if (relativeRatio >= 1.0) { + style['width'] = '100%'; + style['height'] = (relativeRatio * 100) + '%'; + style['left'] = '0'; + style['top'] = (-(relativeRatio - 1.0) / 2 * 100) + '%'; + } else { + style['width'] = ((1 / relativeRatio) * 100) + '%'; + style['height'] = '100%'; + style['left'] = (-((1 / relativeRatio) - 1.0) / 2 * 100) + '%'; + style['top'] = '0'; + } + this.$iframe.css(style); + + void this.$iframe[0].offsetWidth; // Force style addition + this.$iframe.addClass('show'); + }, + /** + * Append background video related elements to the target. + * + * @private + */ + _appendBgVideo: function () { + var $oldContainer = this.$bgVideoContainer || this.$('> .o_bg_video_container'); + this.$bgVideoContainer = $(qweb.render('website.background.video', { + videoSrc: this.videoSrc, + iframeID: this.iframeID, + })); + this.$iframe = this.$bgVideoContainer.find('.o_bg_video_iframe'); + this.$iframe.one('load', () => { + this.$bgVideoContainer.find('.o_bg_video_loading').remove(); + }); + this.$bgVideoContainer.prependTo(this.$target); + $oldContainer.remove(); + + this._adjustIframe(); + + // YouTube does not allow to auto-play video in mobile devices, so we + // have to play the video manually. + if (this.isMobileEnv && this.isYoutubeVideo) { + new window.YT.Player(this.iframeID, { + events: { + onReady: ev => ev.target.playVideo(), + } + }); + } + }, +}); + +registry.ul = publicWidget.Widget.extend({ + selector: 'ul.o_ul_folded, ol.o_ul_folded', + events: { + 'click .o_ul_toggle_next': '_onToggleNextClick', + 'click .o_ul_toggle_self': '_onToggleSelfClick', + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a "toggle next" ul is clicked. + * + * @private + */ + _onToggleNextClick: function (ev) { + ev.preventDefault(); + var $target = $(ev.currentTarget); + $target.toggleClass('o_open'); + $target.closest('li').next().toggleClass('o_close'); + }, + /** + * Called when a "toggle self" ul is clicked. + * + * @private + */ + _onToggleSelfClick: function (ev) { + ev.preventDefault(); + var $target = $(ev.currentTarget); + $target.toggleClass('o_open'); + $target.closest('li').find('ul,ol').toggleClass('o_close'); + }, +}); + +registry.socialShare = publicWidget.Widget.extend({ + selector: '.oe_social_share', + xmlDependencies: ['/website/static/src/xml/website.share.xml'], + events: { + 'mouseenter': '_onMouseEnter', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _bindSocialEvent: function () { + this.$('.oe_social_facebook').click($.proxy(this._renderSocial, this, 'facebook')); + this.$('.oe_social_twitter').click($.proxy(this._renderSocial, this, 'twitter')); + this.$('.oe_social_linkedin').click($.proxy(this._renderSocial, this, 'linkedin')); + }, + /** + * @private + */ + _render: function () { + this.$el.popover({ + content: qweb.render('website.social_hover', {medias: this.socialList}), + placement: 'bottom', + container: this.$el, + html: true, + trigger: 'manual', + animation: false, + }).popover("show"); + + this.$el.off('mouseleave.socialShare').on('mouseleave.socialShare', function () { + var self = this; + setTimeout(function () { + if (!$(".popover:hover").length) { + $(self).popover('dispose'); + } + }, 200); + }); + }, + /** + * @private + */ + _renderSocial: function (social) { + var url = this.$el.data('urlshare') || document.URL.split(/[?#]/)[0]; + url = encodeURIComponent(url); + var title = document.title.split(" | ")[0]; // get the page title without the company name + var hashtags = ' #' + document.title.split(" | ")[1].replace(' ', '') + ' ' + this.hashtags; // company name without spaces (for hashtag) + var socialNetworks = { + 'facebook': 'https://www.facebook.com/sharer/sharer.php?u=' + url, + 'twitter': 'https://twitter.com/intent/tweet?original_referer=' + url + '&text=' + encodeURIComponent(title + hashtags + ' - ') + url, + 'linkedin': 'https://www.linkedin.com/shareArticle?mini=true&url=' + url + '&title=' + encodeURIComponent(title), + }; + if (!_.contains(_.keys(socialNetworks), social)) { + return; + } + var wHeight = 500; + var wWidth = 500; + window.open(socialNetworks[social], '', 'menubar=no, toolbar=no, resizable=yes, scrollbar=yes, height=' + wHeight + ',width=' + wWidth); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user hovers the animation element -> open the social + * links popover. + * + * @private + */ + _onMouseEnter: function () { + var social = this.$el.data('social'); + this.socialList = social ? social.split(',') : ['facebook', 'twitter', 'linkedin']; + this.hashtags = this.$el.data('hashtags') || ''; + + this._render(); + this._bindSocialEvent(); + }, +}); + +registry.anchorSlide = publicWidget.Widget.extend({ + selector: 'a[href^="/"][href*="#"], a[href^="#"]', + events: { + 'click': '_onAnimateClick', + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAnimateClick: function (ev) { + if (this.$target[0].pathname !== window.location.pathname) { + return; + } + var hash = this.$target[0].hash; + if (!utils.isValidAnchor(hash)) { + return; + } + var $anchor = $(hash); + if (!$anchor.length || !$anchor.attr('data-anchor')) { + return; + } + ev.preventDefault(); + $('html, body').animate({ + scrollTop: $anchor.offset().top, + }, 500); + }, +}); + +registry.countdown = publicWidget.Widget.extend({ + selector: '.s_countdown', + xmlDependencies: ['/website/static/src/xml/website.s_countdown.xml'], + disabledInEditableMode: false, + + /** + * @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 (ColorpickerDialog.isCSSColor(color)) { + return color; + } + const style = window.getComputedStyle(document.documentElement); + return style.getPropertyValue('--' + color).trim(); + }, + /** + * 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) { + this.$target.find('.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/>').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/>').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/>').appendTo(this.$wrapper)[0], + total: 60, + label: _t("Minutes"), + nbSeconds: 60, + }); + } + if (this._isUnitVisible('s') || (this.onlyOneUnit && delta < 60)) { + this.diff.push({ + canvas: $('<canvas/>').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) { + return; + } + + // 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; + }, +}); + +registry.chart = 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 this.style.getPropertyValue(`--${color}`).trim() || color; + }, +}); + +return { + Widget: publicWidget.Widget, + Animation: Animation, + registry: registry, + + Class: Animation, // Deprecated +}; +}); diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js new file mode 100644 index 00000000000..47ba28bd791 --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -0,0 +1,1888 @@ +odoo.define('website.editor.snippets.options', function (require) { +'use strict'; + +var core = require('web.core'); +const ColorpickerDialog = require('web.ColorpickerDialog'); +var Dialog = require('web.Dialog'); +var weWidgets = require('wysiwyg.widgets'); +const wUtils = require('website.utils'); +var options = require('web_editor.snippets.options'); + +var _t = core._t; +var qweb = core.qweb; + +options.Class.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Refreshes all public widgets related to the given element. + * + * @private + * @param {jQuery} [$el=this.$target] + * @returns {Promise} + */ + _refreshPublicWidgets: function ($el) { + return new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: $el || this.$target, + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + /** + * @override + */ + _select: async function (previewMode, widget) { + await this._super(...arguments); + + if (!widget.$el.closest('[data-no-widget-refresh="true"]').length) { + // TODO the flag should be retrieved through widget params somehow + await this._refreshPublicWidgets(); + } + }, +}); + +options.registry.background.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getDefaultTextContent: function () { + if (this._getMediaDialogOptions().noVideos) { + return this._super(...arguments); + } + return _t("Choose a picture or a video"); + }, + /** + * @override + */ + _getEditableMedia: function () { + if (!this._hasBgvideo()) { + return this._super(...arguments); + } + return this.$('.o_bg_video_iframe')[0]; + }, + /** + * @override + */ + _getMediaDialogOptions: function () { + return _.extend(this._super(...arguments), { + // For now, disable the possibility to have a parallax video bg + noVideos: this.$target.is('.parallax, .s_parallax_bg'), + isForBgVideo: true, + }); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'chooseImage') { + return this._hasBgvideo() ? 'true' : ''; + } + return this._super(...arguments); + }, + /** + * Updates the background video used by the snippet. + * + * @private + * @see this.selectClass for parameters + * @returns {Promise} + */ + _setBgVideo: async function (previewMode, value) { + this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); + + if (previewMode !== false) { + return; + } + + var target = this.$target[0]; + target.classList.toggle('o_background_video', !!(value && value.length)); + if (value && value.length) { + target.dataset.bgVideoSrc = value; + } else { + delete target.dataset.bgVideoSrc; + } + await this._refreshPublicWidgets(); + await this.updateUI(); + }, + /** + * Returns whether the current target has a background video or not. + * + * @private + * @returns {boolean} + */ + _hasBgvideo: function () { + return this.$target[0].classList.contains('o_background_video'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onBackgroundColorUpdate: function (ev, previewMode) { + var ret = this._super(...arguments); + if (ret) { + this._setBgVideo(previewMode); + } + return ret; + }, + /** + * @override + */ + _onSaveMediaDialog: async function (data) { + if (!data.bgVideoSrc) { + const _super = this._super.bind(this); + const args = arguments; + await this._setBgVideo(false); + return _super(...args); + } + // if the user chose a video, only add the video without removing the + // background + await this._setBgVideo(false, data.bgVideoSrc); + }, +}); + +options.registry.menu_data = options.Class.extend({ + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + + /** + * When the users selects a menu, a dialog is opened to ask him if he wants + * to follow the link (and leave editor), edit the menu or do nothing. + * + * @override + */ + onFocus: function () { + var self = this; + (new Dialog(this, { + title: _t("Confirmation"), + $content: $(core.qweb.render('website.leaving_current_page_edition')), + buttons: [ + {text: _t("Go to Link"), classes: 'btn-primary', click: function () { + self.trigger_up('request_save', { + reload: false, + onSuccess: function () { + window.location.href = self.$target.attr('href'); + }, + }); + }}, + {text: _t("Edit the menu"), classes: 'btn-primary', click: function () { + this.trigger_up('action_demand', { + actionName: 'edit_menu', + params: [ + function () { + var prom = new Promise(function (resolve, reject) { + self.trigger_up('request_save', { + onSuccess: resolve, + onFailure: reject, + }); + }); + return prom; + }, + ], + }); + }}, + {text: _t("Stay on this page"), close: true} + ] + })).open(); + }, +}); + +options.registry.company_data = options.Class.extend({ + /** + * Fetches data to determine the URL where the user can edit its company + * data. Saves the info in the prototype to do this only once. + * + * @override + */ + start: function () { + var proto = options.registry.company_data.prototype; + var prom; + var self = this; + if (proto.__link === undefined) { + prom = this._rpc({route: '/web/session/get_session_info'}).then(function (session) { + return self._rpc({ + model: 'res.users', + method: 'read', + args: [session.uid, ['company_id']], + }); + }).then(function (res) { + proto.__link = '/web#action=base.action_res_company_form&view_type=form&id=' + (res && res[0] && res[0].company_id[0] || 1); + }); + } + return Promise.all([this._super.apply(this, arguments), prom]); + }, + /** + * When the users selects company data, opens a dialog to ask him if he + * wants to be redirected to the company form view to edit it. + * + * @override + */ + onFocus: function () { + var self = this; + var proto = options.registry.company_data.prototype; + + Dialog.confirm(this, _t("Do you want to edit the company data ?"), { + confirm_callback: function () { + self.trigger_up('request_save', { + reload: false, + onSuccess: function () { + window.location.href = proto.__link; + }, + }); + }, + }); + }, +}); + +options.registry.Carousel = options.Class.extend({ + /** + * @override + */ + start: function () { + this.$target.carousel('pause'); + this.$indicators = this.$target.find('.carousel-indicators'); + this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + // Prevent enabling the carousel overlay when clicking on the carousel + // controls (indeed we want it to change the carousel slide then enable + // the slide overlay) + See "CarouselItem" option. + this.$controls.addClass('o_we_no_overlay'); + + let _slideTimestamp; + this.$target.on('slide.bs.carousel.carousel_option', () => { + _slideTimestamp = window.performance.now(); + setTimeout(() => this.trigger_up('hide_overlay')); + }); + this.$target.on('slid.bs.carousel.carousel_option', () => { + // slid.bs.carousel is most of the time fired too soon by bootstrap + // since it emulates the transitionEnd with a setTimeout. We wait + // here an extra 20% of the time before retargeting edition, which + // should be enough... + const _slideDuration = (window.performance.now() - _slideTimestamp); + setTimeout(() => { + this.trigger_up('activate_snippet', { + $snippet: this.$target.find('.carousel-item.active'), + ifInactiveOptions: true, + }); + this.$target.trigger('active_slide_targeted'); + }, 0.2 * _slideDuration); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$target.off('.carousel_option'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + cleanForSave: function () { + const $items = this.$target.find('.carousel-item'); + $items.removeClass('next prev left right active').first().addClass('active'); + this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID for the carousel and reassign data-attributes that + * depend on it. + * + * @private + */ + _assignUniqueID: function () { + const id = 'myCarousel' + Date.now(); + this.$target.attr('id', id); + this.$target.find('[data-target]').attr('data-target', '#' + id); + this.$target.find('[data-slide]').attr('href', '#' + id); + }, +}); + +options.registry.CarouselItem = options.Class.extend({ + isTopOption: true, + + /** + * @override + */ + start: function () { + this.$carousel = this.$target.closest('.carousel'); + this.$indicators = this.$carousel.find('.carousel-indicators'); + this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + var leftPanelEl = this.$overlay.data('$optionsSection')[0]; + var titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.counterEl = document.createElement('span'); + titleTextEl.appendChild(this.counterEl); + + leftPanelEl.querySelector('.oe_snippet_remove').classList.add('d-none'); // TODO improve the way to do that + + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$carousel.off('.carousel_item_option'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the slide counter. + * + * @override + */ + updateUI: async function () { + await this._super(...arguments); + const $items = this.$carousel.find('.carousel-item'); + const $activeSlide = $items.filter('.active'); + const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; + this.counterEl.textContent = updatedText; + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Adds a slide. + * + * @see this.selectClass for parameters + */ + addSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + this.$controls.removeClass('d-none'); + this.$indicators.append($('<li>', { + 'data-target': '#' + this.$target.attr('id'), + 'data-slide-to': $items.length, + })); + // Need to remove editor data from the clone so it gets its own. + const $active = $items.filter('.active'); + $active.clone(false) + .removeClass('active') + .insertAfter($active); + this.$carousel.carousel('next'); + }, + /** + * Removes the current slide. + * + * @see this.selectClass for parameters. + */ + removeSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + const newLength = $items.length - 1; + if (!this.removing && newLength > 0) { + const $toDelete = $items.filter('.active'); + this.$carousel.one('active_slide_targeted.carousel_item_option', () => { + $toDelete.remove(); + this.$indicators.find('li:last').remove(); + this.$controls.toggleClass('d-none', newLength === 1); + this.$carousel.trigger('content_changed'); + this.removing = false; + }); + this.removing = true; + this.$carousel.carousel('prev'); + } + }, + /** + * Goes to next slide or previous slide. + * + * @see this.selectClass for parameters + */ + slide: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'left': + this.$controls.filter('.carousel-control-prev')[0].click(); + break; + case 'right': + this.$controls.filter('.carousel-control-next')[0].click(); + break; + } + }, +}); + +options.registry.navTabs = options.Class.extend({ + /** + * @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-link'); + var $el = this.$target; + do { + $el = $el.parent(); + this.$tabPanes = $el.find('.tab-pane'); + } while (this.$tabPanes.length === 0 && !$el.is('body')); + }, + /** + * @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.sizing_x = options.registry.sizing.extend({ + /** + * @override + */ + onClone: function (options) { + this._super.apply(this, arguments); + // Below condition is added to remove offset of target element only + // and not its children to avoid design alteration of a container/block. + if (options.isCurrent) { + var _class = this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-)([0-9-]+)/g, ''); + this.$target.attr('class', _class); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + var width = this.$target.closest('.row').width(); + var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + this.grid = { + e: [_.map(gridE, v => ('col-lg-' + v)), _.map(gridE, v => width / 12 * v), 'width'], + w: [_.map(gridW, v => ('offset-lg-' + v)), _.map(gridW, v => width / 12 * v), 'margin-left'], + }; + return this.grid; + }, + /** + * @override + */ + _onResize: function (compass, beginClass, current) { + if (compass === 'w') { + // don't change the right border position when we change the offset (replace col size) + var beginCol = Number(beginClass.match(/col-lg-([0-9]+)|$/)[1] || 0); + var beginOffset = Number(beginClass.match(/offset-lg-([0-9-]+)|$/)[1] || beginClass.match(/offset-xl-([0-9-]+)|$/)[1] || 0); + var offset = Number(this.grid.w[0][current].match(/offset-lg-([0-9-]+)|$/)[1] || 0); + if (offset < 0) { + offset = 0; + } + var colSize = beginCol - (offset - beginOffset); + if (colSize <= 0) { + colSize = 1; + offset = beginOffset + beginCol - 1; + } + this.$target.attr('class', this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-|col-lg-)([0-9-]+)/g, '')); + + this.$target.addClass('col-lg-' + (colSize > 12 ? 12 : colSize)); + if (offset > 0) { + this.$target.addClass('offset-lg-' + offset); + } + } + this._super.apply(this, arguments); + }, +}); + +options.registry.layout_column = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the number of columns. + * + * @see this.selectClass for parameters + */ + selectCount: function (previewMode, widgetValue, params) { + const nbColumns = parseInt(widgetValue); + this._updateColumnCount(nbColumns - this.$target.children().length); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectCount') { + return '' + this.$target.children().length; + } + return this._super(...arguments); + }, + /** + * Adds new columns which are clones of the last column or removes the + * last x columns. + * + * @private + * @param {integer} count - positif to add, negative to remove + */ + _updateColumnCount: function (count) { + if (!count) { + return; + } + + this.trigger_up('request_history_undo_record', {$target: this.$target}); + + if (count > 0) { + var $lastColumn = this.$target.children().last(); + for (var i = 0; i < count; i++) { + $lastColumn.clone().insertAfter($lastColumn); + } + } else { + var self = this; + _.each(this.$target.children().slice(count), function (el) { + self.trigger_up('remove_snippet', {$snippet: $(el)}); + }); + } + + this._resizeColumns(); + this.trigger_up('cover_update'); + }, + /** + * Resizes the columns so that they are kept on one row. + * + * @private + */ + _resizeColumns: function () { + var $columns = this.$target.children(); + var colsLength = $columns.length; + var colSize = Math.floor(12 / colsLength) || 1; + var colOffset = Math.floor((12 - colSize * colsLength) / 2); + var colClass = 'col-lg-' + colSize; + _.each($columns, function (column) { + var $column = $(column); + $column.attr('class', $column.attr('class').replace(/\b(col|offset)-lg(-\d+)?\b/g, '')); + $column.addClass(colClass); + }); + if (colOffset) { + $columns.first().addClass('offset-lg-' + colOffset); + } + }, +}); + +options.registry.parallax = options.Class.extend({ + /** + * @override + */ + onFocus: function () { + this.trigger_up('option_update', { + optionNames: ['background', 'BackgroundPosition'], + name: 'target', + data: this.$target.find('> .s_parallax_bg'), + }); + // Refresh the parallax animation on focus; at least useful because + // there may have been changes in the page that influenced the parallax + // rendering (new snippets, ...). + // TODO make this automatic. + this._refreshPublicWidgets(); + }, + /** + * @override + */ + onMove: function () { + this._refreshPublicWidgets(); + }, +}); + +options.registry.ul = options.Class.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.$target.on('mouseup', '.o_ul_toggle_self, .o_ul_toggle_next', function () { + self.trigger_up('cover_update'); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + cleanForSave: function () { + this._super(); + if (!this.$target.hasClass('o_ul_folded')) { + this.$target.find('.o_close').removeClass('o_close'); + this.$target.find('li').css('list-style', ''); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + selectClass: async function () { + await this._super.apply(this, arguments); + + this.trigger_up('widgets_stop_request', { + $target: this.$target, + }); + + this.$target.find('.o_ul_toggle_self, .o_ul_toggle_next').remove(); + this.$target.find('li:has(>ul,>ol)').map(function () { + // get if the li contain a text label + var texts = _.filter(_.toArray(this.childNodes), a => (a.nodeType === 3)); + if (!texts.length || !texts.reduce((a, b) => (a.textContent + b.textContent)).match(/\S/)) { + return; + } + $(this).children('ul,ol').addClass('o_close'); + return $(this).children(':not(ul,ol)')[0] || this; + }) + .prepend('<a href="#" class="o_ul_toggle_self fa" />'); + var $li = this.$target.find('li:has(+li:not(>.o_ul_toggle_self)>ul, +li:not(>.o_ul_toggle_self)>ol)'); + $li.css('list-style', this.$target.hasClass('o_ul_folded') ? 'none' : ''); + $li.map((i, el) => ($(el).children()[0] || el)) + .prepend('<a href="#" class="o_ul_toggle_next fa" />'); + $li.removeClass('o_open').next().addClass('o_close'); + this.$target.find('li').removeClass('o_open'); + }, +}); + +options.registry.collapse = options.Class.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.$target.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () { + self.trigger_up('cover_update'); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._createIDs(); + }, + /** + * @override + */ + onClone: function () { + this.$target.find('[data-toggle="collapse"]').removeAttr('data-target').removeData('target'); + this.$target.find('.collapse').removeAttr('id'); + this._createIDs(); + }, + /** + * @override + */ + onMove: function () { + this._createIDs(); + var $panel = this.$target.find('.collapse').removeData('bs.collapse'); + if ($panel.attr('aria-expanded') === 'true') { + $panel.closest('.accordion').find('.collapse[aria-expanded="true"]') + .filter((i, el) => (el !== $panel[0])) + .collapse('hide') + .one('hidden.bs.collapse', function () { + $panel.trigger('shown.bs.collapse'); + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Associates unique ids on collapse elements. + * + * @private + */ + _createIDs: function () { + var time = new Date().getTime(); + var $tab = this.$target.find('[data-toggle="collapse"]'); + + // link to the parent group + var $tablist = this.$target.closest('.accordion'); + var tablist_id = $tablist.attr('id'); + if (!tablist_id) { + tablist_id = 'myCollapse' + time; + $tablist.attr('id', tablist_id); + } + $tab.attr('data-parent', '#' + tablist_id); + $tab.data('parent', '#' + tablist_id); + + // link to the collapse + var $panel = this.$target.find('.collapse'); + var panel_id = $panel.attr('id'); + if (!panel_id) { + while ($('#' + (panel_id = 'myCollapseTab' + time)).length) { + time++; + } + $panel.attr('id', panel_id); + } + $tab.attr('data-target', '#' + panel_id); + $tab.data('target', '#' + panel_id); + }, +}); + +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.find('.container').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]; + } + 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'); + }, +}); + +options.registry.topMenuTransparency = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles the toggling between normal and overlay positions of the header. + * + * @see this.selectClass for params + */ + transparent: function (previewMode, widgetValue, params) { + var self = this; + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_overlay'}], + onSuccess: function () { + self.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: ''}], + }); + }, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'transparent') { + return new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: v => resolve(v ? 'true' : ''), + }); + }); + } + return this._super(...arguments); + }, +}); + +options.registry.topMenuColor = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility: async function () { + const show = await this._super(...arguments); + if (!show) { + return false; + } + return new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: value => resolve(!!value), + }); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onColorButtonClick: function () { + this._super.apply(this, arguments); + var bgs = this.$target.attr('class').match(/bg-(\w|-)+/g); + var allowedBgs = this.classes.split(' '); + var color = _.intersection(bgs, allowedBgs).join(' '); + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: color}], + }); + }, +}); + +/** + * Handles the edition of snippet's anchor name. + */ +options.registry.anchor = options.Class.extend({ + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + isTopOption: true, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + // Generate anchor and copy it to clipboard on click, show the tooltip on success + this.$button = this.$el.find('we-button'); + const clipboard = new ClipboardJS(this.$button[0], {text: () => this._getAnchorLink()}); + clipboard.on('success', () => { + const anchor = decodeURIComponent(this._getAnchorLink()); + this.displayNotification({ + title: _t("Copied !"), + message: _.str.sprintf(_t("The anchor has been copied to your clipboard.<br>Link: %s"), anchor), + buttons: [{text: _t("edit"), click: () => this.openAnchorDialog()}], + }); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onClone: function () { + this.$target.removeAttr('id data-anchor'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * @see this.selectClass for parameters + */ + openAnchorDialog: function (previewMode, widgetValue, params) { + var self = this; + var buttons = [{ + text: _t("Save & copy"), + classes: 'btn-primary', + click: function () { + var $input = this.$('.o_input_anchor_name'); + var anchorName = self._text2Anchor($input.val()); + if (self.$target[0].id === anchorName) { + // If the chosen anchor name is already the one used by the + // element, close the dialog and do nothing else + this.close(); + return; + } + + const alreadyExists = !!document.getElementById(anchorName); + this.$('.o_anchor_already_exists').toggleClass('d-none', !alreadyExists); + $input.toggleClass('is-invalid', alreadyExists); + if (!alreadyExists) { + self._setAnchorName(anchorName); + this.close(); + self.$button[0].click(); + } + }, + }, { + text: _t("Discard"), + close: true, + }]; + if (this.$target.attr('id')) { + buttons.push({ + text: _t("Remove"), + classes: 'btn-link ml-auto', + icon: 'fa-trash', + close: true, + click: function () { + self._setAnchorName(); + }, + }); + } + new Dialog(this, { + title: _t("Link Anchor"), + $content: $(qweb.render('website.dialog.anchorName', { + currentAnchor: decodeURIComponent(this.$target.attr('id')), + })), + buttons: buttons, + }).open(); + }, + /** + * @private + * @param {String} value + */ + _setAnchorName: function (value) { + if (value) { + this.$target.attr({ + 'id': value, + 'data-anchor': true, + }); + } else { + this.$target.removeAttr('id data-anchor'); + } + this.$target.trigger('content_changed'); + }, + /** + * Returns anchor text. + * + * @private + * @returns {string} + */ + _getAnchorLink: function () { + if (!this.$target[0].id) { + const $titles = this.$target.find('h1, h2, h3, h4, h5, h6'); + const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName; + const anchorName = this._text2Anchor(title); + let n = ''; + while (document.getElementById(anchorName + n)) { + n = (n || 1) + 1; + } + this._setAnchorName(anchorName + n); + } + return `#${this.$target[0].id}`; + }, + /** + * Creates a safe id/anchor from text. + * + * @private + * @param {string} text + * @returns {string} + */ + _text2Anchor: function (text) { + return encodeURIComponent(text.trim().replace(/\s+/g, '-')); + }, +}); + +/** + * Allows edition of 'cover_properties' in website models which have such + * fields (blogs, posts, events, ...). + */ +options.registry.CoverProperties = options.Class.extend({ + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this.$image = this.$target.find('.o_record_cover_image'); + this.$filter = this.$target.find('.o_record_cover_filter'); + }, + /** + * @override + */ + start: function () { + this.$filterValueOpts = this.$el.find('[data-filter-value]'); + this.$filterColorOpts = this.$el.find('[data-filter-color]'); + this.filterColorClasses = this.$filterColorOpts.map(function () { + return $(this).data('filterColor'); + }).get().join(' '); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + clear: function (previewMode, widgetValue, params) { + this.$target.removeClass('o_record_has_cover'); + this.$image.css('background-image', ''); + }, + /** + * @see this.selectClass for parameters + */ + change: function (previewMode, widgetValue, params) { + var $image = $('<img/>'); + var background = this.$image.css('background-image'); + if (background && background !== 'none') { + $image.attr('src', background.match(/^url\(["']?(.+?)["']?\)$/)[1]); + } + + return new Promise(resolve => { + var editor = new weWidgets.MediaDialog(this, { + mediaWidth: 1920, + onlyImages: true, + firstFilters: ['background'] + }, $image[0]).open(); + editor.on('save', this, function (image) { + var src = image.src; + this.$image.css('background-image', src ? ('url(' + src + ')') : ''); + if (!this.$target.hasClass('o_record_has_cover')) { + this.$el.find('.o_record_cover_opt_size_default[data-select-class]').click(); + } + resolve(); + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + filterValue: function (previewMode, widgetValue, params) { + this.$filter.css('opacity', widgetValue || 0); + }, + /** + * @see this.selectClass for parameters + */ + filterColor: function (previewMode, widgetValue, params) { + this.$filter.removeClass(this.filterColorClasses); + if (widgetValue) { + this.$filter.addClass(widgetValue); + } + + var $firstVisibleFilterOpt = this.$filterValueOpts.eq(1); + if (parseFloat(this.$filter.css('opacity')) < parseFloat($firstVisibleFilterOpt.data('filterValue'))) { + this.filterValue(previewMode, $firstVisibleFilterOpt.data('filterValue'), $firstVisibleFilterOpt); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @private + */ + updateUI: async function () { + await this._super(...arguments); + + // Update saving dataset + this.$target[0].dataset.coverClass = this.$el.find('[data-cover-opt-name="size"] .active').data('selectClass') || ''; + this.$target[0].dataset.textSizeClass = this.$el.find('[data-cover-opt-name="text_size"] .active').data('selectClass') || ''; + this.$target[0].dataset.textAlignClass = this.$el.find('[data-cover-opt-name="text_align"] .active').data('selectClass') || ''; + this.$target[0].dataset.filterValue = this.$filterValueOpts.filter('.active').data('filterValue') || 0.0; + this.$target[0].dataset.filterColor = this.$filterColorOpts.filter('.active').data('filterColor') || ''; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'filterValue': { + return parseFloat(this.$filter.css('opacity')).toFixed(1); + } + case 'filterColor': { + const classes = this.filterColorClasses.split(' '); + for (const className of classes) { + if (this.$filter.hasClass(className)) { + return className; + } + } + return ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + const hasCover = this.$target.hasClass('o_record_has_cover'); + if (widgetName === 'remove_cover_opt') { + return hasCover; + } else if (params.coverOptName) { + var notAllowed = (this.$target.data(`use_${params.coverOptName}`) !== 'True'); + return (hasCover && !notAllowed); + } + return this._super(...arguments); + }, +}); + +/** + * Whether the section should be full-width (container-fluid) or use a classic container + */ +options.registry.SectionStretch = options.Class.extend({ + /** + * Only shows this option if it changes the visual. + * + * @override + */ + start: function () { + const $container = $('<div>', {class: 'container'}).insertAfter(this.$target); + const sizeDifference = this.$target.parent().width() / $container.outerWidth() - 1; + $container.remove(); + // The cutoff for the option is 5% difference in width + if (sizeDifference < 0.05) { + this.$el.addClass('d-none'); + } + + return this._super.apply(this, arguments); + }, +}); + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +options.registry.SnippetMove = options.Class.extend({ + /** + * @override + */ + start: function () { + var $buttons = this.$el.find('we-button'); + var $overlayArea = this.$overlay.find('.o_overlay_options'); + $overlayArea.prepend($buttons[0]); + $overlayArea.append($buttons[1]); + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet around. + * + * @see this.selectClass for parameters + */ + moveSnippet: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'prev': + this.$target.prev().before(this.$target); + break; + case 'next': + this.$target.next().after(this.$target); + break; + } + }, +}); + +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 = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + 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 Background Color") : _t("Dataset Background Color"); + this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border Color") : _t("Dataset Border Color"); + + // 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 = ColorpickerDialog.isCSSColor(color) ? color : this.style.getPropertyValue(`--${color}`).trim(); + } + }); + }, + + //-------------------------------------------------------------------------- + // 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: ['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 !this.style.getPropertyValue(`--${el}`) && 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(); + }, +}); + +const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; +const UrlPickerUserValueWidget = InputUserValueWidget.extend({ + custom_events: _.extend({}, InputUserValueWidget.prototype.custom_events || {}, { + website_url_chosen: '_onWebsiteURLChosen', + }), + + /** + * @override + */ + start: async function () { + await this._super(...arguments); + $(this.inputEl).addClass('text-left'); + wUtils.autocompleteWithPages(this, $(this.inputEl)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the autocomplete change the input value. + * + * @private + */ + _onWebsiteURLChosen: function () { + $(this.inputEl).trigger('input'); + this._notifyValueChange(false); + }, +}); + +options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; +return { + UrlPickerUserValueWidget: UrlPickerUserValueWidget, +}; +}); diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss new file mode 100644 index 00000000000..4a9729fb1f8 --- /dev/null +++ b/addons/website/static/src/scss/website.scss @@ -0,0 +1,921 @@ +/// +/// This file regroups the website design rules. +/// + +$-seen: (); +$-font-numbers: ( + o-website-value('font-number'), + o-website-value('headings-font-number'), + o-website-value('navbar-font-number'), + o-website-value('buttons-font-number'), +); +@each $-number in $-font-numbers { + @if index($-seen, $-number) == null { + $-seen: append($-seen, $-number); + $-theme-font: nth($o-theme-font-urls, $-number); + @if $-theme-font { + @import url("https://fonts.googleapis.com/css?family=#{unquote($-theme-font)}&display=swap"); + } + } +} + +:root { + // The theme customize modal JS will need to know the value of some scss + // variables used to render the user website, and those may have been + // customized by themes, the user or anything else (so there is no file to + // parse to get them). Those will be printed here as CSS variables. + + // 1) Handle default values + --header-font-size: #{$font-size-base}; + + // 2) The values in the $theme-colors map are already printed by Bootstrap. + + // 3) The values in the $colors map are also printed by Bootstrap. + + // 4) The Odoo values map, $o-website-values, must be printed. + @each $key, $value in $o-website-values { + --#{$key}: #{$value}; + } + + // 5) Use final value used by the theme + $-h1-color: if($headings-color == inherit or not $headings-color, $body-color, $headings-color); + $-h2-color: if(color('h2'), color('h2'), $-h1-color); + $-h3-color: if(color('h3'), color('h3'), $-h2-color); + $-h4-color: if(color('h4'), color('h4'), $-h3-color); + $-h5-color: if(color('h5'), color('h5'), $-h4-color); + $-h6-color: if(color('h6'), color('h6'), $-h5-color); + + --body: #{$body-bg}; + --text: #{$body-color}; + --h1: #{$-h1-color}; + --h2: #{$-h2-color}; + --h3: #{$-h3-color}; + --h4: #{$-h4-color}; + --h5: #{$-h5-color}; + --h6: #{$-h6-color}; + + --logo-height: #{$o-theme-navbar-logo-height}; + --number-of-fonts: #{length($o-theme-fonts)}; +} + +#wrapwrap { + background-size: cover; + background-repeat: no-repeat; + background-position: center; + background-attachment: fixed; + + @if ($o-theme-layout == 'boxed') { + @include media-breakpoint-up(md) { + padding: 0 7.5%; + } + + #wrap { + background-color: $white; + } + } +} + +.navbar { + @if ($o-theme-navbar-font != $o-theme-font) { + font-family: $o-theme-navbar-font; + } + + .nav-item { + transition: opacity 1000ms ease 0s; + } + .o_menu_loading .nav-item { + height: 0 !important; + overflow: hidden !important; + opacity: 0 !important; + } +} + +.navbar-light { + // Style only navbar-light which Odoo is only supposed to use in standard + // anyway. Automatically mimic navbar-dark if the user's menu color is dark. + // Note: this only works because navbar-light is defined before navbar-dark, + // we may want to use a safest way when possible. + @if color('menu') { + background-color: color('menu') !important; + @if (color-yiq(color('menu')) != $yiq-text-dark) { + @extend .navbar-dark; + } + } +} + +$-header-nav-link-height: $nav-link-height; +@if o-website-value('header-font-size') { + $-header-nav-link-height: o-website-value('header-font-size') * $line-height-base + $nav-link-padding-y * 2; + header { + font-size: o-website-value('header-font-size'); + + .dropdown-menu { + font-size: inherit; + } + } +} +@if $o-theme-navbar-logo-height { + // With default values, this makes it slightly bigger than standard + // navbar-brand, which is what we want + header .navbar-brand { + font-size: $o-theme-navbar-logo-height / $line-height-base; + + $-logo-padding-y: max(0, $-header-nav-link-height - $o-theme-navbar-logo-height) / 2; + &, &.logo { + padding-top: $-logo-padding-y; + padding-bottom: $-logo-padding-y; + } + } +} + +.o_footer { + @if color('footer') { + @include o-bg-color(color('footer')); + } + + > #footer { + // FIXME needed to prevent the dropzone at the bottom of the #wrap element + // to be at the same place of the dropzone at the top of the #footer element + border-top: 1px solid transparent; + } +} +h2, h3, h4, h5, h6 { + color: color('h2'); +} +h3, h4, h5, h6 { + color: color('h3'); +} +h4, h5, h6 { + color: color('h4'); +} +h5, h6 { + color: color('h5'); +} +h6 { + color: color('h6'); +} +.btn { + @if ($o-theme-buttons-font != $o-theme-font) { + font-family: $o-theme-buttons-font; + } +} + +// Texts +font[style*='background'], +font[class*='bg-'] { + padding: 2px 6px 4px; +} + +// Icons +.fa { + font-family: "FontAwesome" !important; + $size: 3rem; + + &.rounded-circle, + &.rounded, + &.rounded-0, + &.rounded-leaf, + &.img-thumbnail, + &.shadow { + display: inline-block; + vertical-align: middle; + text-align: center; + // fa-1x is not ouput + @include size($size); + line-height: $size; + @for $i from 2 through 5 { + &.fa-#{$i}x { + @include size($size + $i); + line-height: $size + $i; + } + } + // Default, if no background-color already selected + background-color: $gray-100; + } + &.img-thumbnail { + padding: 0; + } + &.rounded-leaf { + border-top-left-radius: $size; + border-bottom-right-radius: $size; + } +} + +// Buttons +.btn { + &.flat { + border: 0; + letter-spacing: 0.05em; + text-transform: uppercase; + @include button-size(0.75rem, 1.5rem, ($font-size-base * .75), $btn-line-height, 0); + &.btn-lg { @include button-size(1rem, 2rem, ($font-size-lg * .75), $btn-line-height-lg, 0); } + &.btn-sm { @include button-size(.5rem, 1rem, ($font-size-sm * .75), $btn-line-height-sm, 0); } + &.btn-xs { @include button-size(.25rem, .5rem, ($font-size-base * .5), $btn-line-height-sm, 0); } + } + &.rounded-circle { + border-radius: 100px !important; + @include button-size(0.45rem, 1.35rem, $font-size-base, $btn-line-height, 30px); + &.btn-lg { @include button-size(.6rem, 1.8rem, $font-size-lg, $btn-line-height-lg, 30px); } + &.btn-sm { @include button-size(.3rem, .9rem, $font-size-sm, $btn-line-height-sm, 30px); } + &.btn-xs { @include button-size(.15rem, .45rem, ($font-size-base * .75), $btn-line-height-sm, 30px); } + } +} + +// Blockquotes +.blockquote { + font-size: 1rem; + footer { + background-color: inherit; + } +} + +// Background Images +.oe_img_bg { + background-size: cover; + background-repeat: no-repeat; + + &.o_bg_img_opt_repeat { + background-size: auto; + background-repeat: repeat; + } + + // Compatibility <= 13.0, TODO remove? + // ----------------------------------- + &.o_bg_img_opt_contain { + background-size: contain; + background-position: center center; + } + &.o_bg_img_opt_custom { + background-size: auto; + } + &.o_bg_img_opt_repeat_x { + background-repeat: repeat-x; + } + &.o_bg_img_opt_repeat_y { + background-repeat: repeat-y; + } +} + +// Background videos +.o_background_video { + position: relative; + + > * { + position: relative; + } +} +.o_bg_video_container { + @include o-position-absolute(0, 0 ,0 ,0); + overflow: hidden; +} +.o_bg_video_iframe { + position: relative; + pointer-events: none !important; +} +.o_bg_video_loading { + @include o-position-absolute(0, 0 ,0 ,0); +} + +// Probably outdated +// Lists +.o_ul_toggle { + display: none; +} + +.o_ul_folded { + .o_close { + display: none!important; + } + .o_ul_toggle_self, + .o_ul_toggle_next { + display: inline-block; + line-height: inherit; + float: left; + position: relative; + margin-left: -1em; + top: -0.15em; + left: 0.2em; + font-size: 1.4em; + text-decoration: none; + &.o_open:before { + content: ""; + } + &:before { + content: ""; + } + } +} + +// Probably outdated +// Disable fixed height +@media (max-width: 400px) { + section, + .parallax, + .row, + .hr, + .blockquote { + height: auto !important; + } +} + +// Probably outdated +// Table +.table_desc { + margin: 0 0 20px 0; + width: 100%; + word-break: break-all; + border: 1px solid #dddddd; +} +.table_heading { + background-color: #f5f5f5; + border: 1px solid #dddddd; + color: #666666; + font-size: 14px; + padding: 4px; +} +table.table_desc tr td { + text-align: left; + padding: 5px; + font-size: 13px; + &:first-child { + width: 25%; + font-weight: bold; + border-bottom: 1px solid #c9c9c9; + border-right: 1px solid #c9c9c9; + border-left: none; + } + &:last-child { + border-bottom: 1px solid #c9c9c9; + } +} + +// Jumbotron +.jumbotron { + border-radius: 0; +} + +// +// Snippets +// + +// Title +.s_title { + .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; + } +} + + +// Features Grid +.s_features_grid { + &_content { + overflow: hidden; + p { + margin-bottom: 0; + } + } + &_icon { + float: left; + margin-right: $grid-gutter-width/2; + } +} + + +// Comparisons +.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%; + } + } +} + + +// FAQ +.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; + } +} + + +// References +.s_references .img-thumbnail { + border: none; +} + +// Carousel -> TODO: should be versioned in 000.scss file but how ? +.s_carousel, +.s_quotes_carousel { + + // Controls + .carousel-control-prev, + .carousel-control-next { + cursor: pointer; + width: 8%; + opacity: 1; + } + .carousel-control-prev { justify-content: flex-start; } + .carousel-control-next { justify-content: flex-end; } + .carousel-control-prev-icon, + .carousel-control-next-icon { + @include size(auto); + background-image: none; + color: $body-color; + &:before { + font-family: "FontAwesome"; + display: inline-block; + background-color: #fff; + } + } + // Content + .carousel-inner { + overflow: hidden; + height: 100%; + .carousel-item { + height: 100%; + } + } + // Indicators + .carousel-indicators li:hover:not(.active) { + background-color: rgba(255,255,255,.8); + } + + // Default + &.s_carousel_default { + // Controls - chevron + .carousel-control-prev-icon:before { content: "\f053" #{"/*rtl:'\f054'*/"}; margin-left: 1.5rem; } + .carousel-control-next-icon:before { content: "\f054" #{"/*rtl:'\f053'*/"}; margin-right: 1.5rem; } + .carousel-control-prev-icon:before, + .carousel-control-next-icon:before { + background-color: rgba(0,0,0,0); + font-size: 2rem; + color: #fff; + text-shadow: $box-shadow-sm; + } + // Indicators + .carousel-indicators li { + height: .5rem; + box-shadow: $box-shadow-sm; + border-radius: 2px; + } + } + + // Border + &.s_carousel_bordered { + border: 2rem solid rgba(0,0,0,0); + // Controls - caret + .carousel-control-prev-icon:before { content: "\f0d9"; } + .carousel-control-next-icon:before { content: "\f0da"; } + .carousel-control-prev-icon:before, + .carousel-control-next-icon:before { + @include size(2rem, 6rem); + line-height: 6rem; + font-size: 1.5rem; + } + // Indicators + .carousel-indicators li { + @include size(3rem, 1rem); + } + } + + // Circle + &.s_carousel_rounded { + // Container + // .carousel-inner { + // border-top-left-radius: 10rem; + // border-bottom-right-radius: 10rem; + // } + // Controls - arrow + .carousel-control-prev { margin-left: 1.5rem; } + .carousel-control-next { margin-right: 1.5rem; } + .carousel-control-prev-icon:before { content: "\f060"; } + .carousel-control-next-icon:before { content: "\f061"; } + .carousel-control-prev-icon:before, + .carousel-control-next-icon:before { + @include size(4rem); + line-height: 4rem; + border-radius: 50%; + font-size: 1.25rem; + } + // Indicators + .carousel-indicators li { + @include size(1rem); + border-radius: 50%; + } + } + + // Boxed + &.s_carousel_boxed { + @include make-container(); + @include make-container-max-widths(); + .carousel-item { + padding: 0 1rem; + } + // Controls - angle + .carousel-control-prev, + .carousel-control-next { + align-items: flex-end; + margin-bottom: 1.25rem; + } + .carousel-control-prev { margin-left: 3rem; } + .carousel-control-next { margin-right: 3rem; } + .carousel-control-prev-icon:before { content: "\f104"; } + .carousel-control-next-icon:before { content: "\f105"; } + .carousel-control-prev-icon:before, + .carousel-control-next-icon:before { + @include size(2rem); + line-height: 2rem; + font-size: 1.25rem; + } + // Indicators + .carousel-indicators li { + @include size(1rem); + &:hover:not(.active) { + background-color: rgba(255,255,255,.8); + } + } + } +} + + +.carousel .container { + .carousel-img img { + max-height: 95%; + padding: 10px; + } + > .carousel-caption { + @include o-position-absolute($right: 50%, $left: 50%); + bottom: 20px; + > div { + position: absolute; + text-align: left; + padding: 20px; + background: rgba(0, 0, 0, 0.4); + bottom: 20px; + } + } + > .carousel-image { + @include o-position-absolute($top: 5%, $bottom: 5%); + max-height: 90%; + margin: 0 auto; + } + .carousel-item.text_image .container { + > .carousel-caption { + left: 10%; + > div { + right: 50%; + margin-right: -20%; + max-width: 550px; + } + } + > .carousel-image { + right: 10%; + left: 50%; + } + } + .carousel-item.image_text .container { + > .carousel-caption { + right: 10%; + > div { + left: 50%; + margin-left: -20%; + max-width: 550px; + } + } + > .carousel-image { + right: 50%; + left: 10%; + } + } + .carousel-item.text_only .container { + > .carousel-caption { + left: 10%; + right: 10%; + top: 10%; + bottom: auto; + > div { + text-align: center; + background: transparent; + bottom: auto; + width: 100%; + } + } + > .carousel-image { + display: none !important; + } + } +} + + +// Quotes Slider +.s_quotes_carousel { + 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:""; + } + } + } +} + +// Company Team +.s_company_team { + @include media-breakpoint-down(md) { + img { + max-width: 50%; + } + } +} + +// Parallax +.parallax { + position: relative; + + // TODO this introduces a limitation: no dropdown will be able to + // overflow. Maybe there is a better way to find. + &:not(.s_parallax_no_overflow_hidden) { + overflow: hidden; + } + + > * { + position: relative; + } + > .s_parallax_bg { + @include o-position-absolute(0, 0, 0, 0); + display: block; + background-color: inherit; + background-size: cover; + background-attachment: scroll; + pointer-events: none; + + &::after { + content: ""; + @include o-position-absolute(0, 0, 0, 0); + display: block; + background-color: inherit; + } + } + @include media-breakpoint-up(xl) { + // Fixed backgrounds are disabled when using a mobile/tablet device, + // which is not a big deal but, on some of them (iOS...), defining the + // background as fixed breaks the background-size/position props. + // So we enable this only for >= XL devices. + &.s_parallax_is_fixed > .s_parallax_bg { + background-attachment: fixed; + } + } +} + + +// +// Layout +// + +// Header +.o_header_affix { + display: block; + @include o-position-absolute(0, 0, auto, 0); + position: fixed; + z-index: $zindex-modal - 20; + background: $light; + margin-top: -999px; + transition: margin-top 500ms ease 0s; + @include media-breakpoint-down(sm) { + .navbar-collapse { + max-height: 70vh; + overflow-y: auto; + } + } + &.affixed { + margin-top: 0px !important; // the default margin-top is adapted in JS + } +} +#oe_main_menu_navbar + #wrapwrap .o_header_affix { + top: $o-navbar-height; +} + +// Navbar +.navbar .o_extra_menu_items.show > ul { + > li { + + li { + border-top: 1px solid gray('200'); + } + > a.dropdown-toggle { + background-color: gray('200'); + color: inherit; // Useful when the toggle is active + pointer-events: none; // hack to prevent clicking on it because dropdown always opened + } + > ul { // remove dropdown-menu default style as it is nested in another one + position: static; + float: none; + display: block; + max-height: none; + margin-top: 0; + padding: 0; + border: none; + box-shadow: none; + } + } +} + +// Mega menu +.o_mega_menu { + width: 100%; + padding: 0; + margin-top: 0; + border-radius: 0; + background-clip: unset; // Remove the 1px gap introduced by BS4 + + .container, .container-fluid { + // Need to reforce those because they are removed since its a container + // inside another container (the one in the navbar) + padding-left: $grid-gutter-width / 2; + padding-right: $grid-gutter-width / 2; + } +} +.o_mega_menu_container_size { + @include media-breakpoint-up(md) { + left: 50%; + transform: translateX(-50%); + } + + $-mm-max-widths: (); + @each $k, $v in $container-max-widths { + $-mm-max-widths: map-merge($-mm-max-widths, ( + #{$k}: $v - $grid-gutter-width, + )); + } + @include make-container-max-widths($-mm-max-widths); +} + +// Copyright +.o_footer_copyright { + padding: 8px; + .js_language_selector { + display: inline-block; + } + @include media-breakpoint-up(md) { + .row { + display: flex; + > div { + margin: auto 0; + } + } + } +} + +#wrapwrap.o_header_overlay { + > header:not(.o_header_affix) { + @include o-position-absolute(0, 0, auto, 0); + z-index: 1000; + + > .navbar { + background-color: transparent !important; + border-color: transparent; + + .nav-item { + > .nav-link { + background-color: transparent; + color: inherit; + } + &.show, &.active { + > .nav-link { + background-color: white; + color: black; + } + } + } + } + } +} +// Figure with special style +.o_figure_relative_layout { + position: relative; + + .figure-img { + margin-bottom: 0; + } + .figure-caption { + @include o-position-absolute(auto, 0, 0, 0); + @include o-bg-color(rgba(theme-color('dark'), $o-theme-figcaption-opacity)); + padding: $tooltip-padding-y $tooltip-padding-x; + font-weight: $font-weight-bold; + a { + color: inherit; + } + } +} + +@each $color, $value in $theme-colors { + .bg-#{$color}-light { + background-color: rgba($value, 0.1); + } +} + +@each $media, $color in $o-social-colors { + @include text-emphasis-variant(".text-#{$media}", $color); +} + +// TODO: Will be handled properly in master/saas-12.2, temp fix for website_event.registration_attendee_details +.modal-footer > .float-left { + margin-right: auto; +} + +// CoverProperties +.o_record_cover_container { + position: relative; + + .o_record_cover_component { + @include o-position-absolute(0, 0, 0, 0); + + background-size: cover; + background-position: center; + background-repeat: no-repeat; + } +} 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 00000000000..72a9f2a0fe1 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/000.js @@ -0,0 +1,172 @@ +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: '.o_gallery:not(.o_slideshow)', + xmlDependencies: ['/website/static/src/xml/website.gallery.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 urls = []; + var idx = undefined; + var milliseconds = undefined; + var params = undefined; + var $images = $cur.closest('.o_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) + }; + + $images.each(function () { + urls.push($(this).attr('src')); + }); + var $img = ($cur.is('img') === true) ? $cur : $cur.closest('img'); + idx = urls.indexOf($img.attr('src')); + + milliseconds = $cur.closest('.o_gallery').data('interval') || false; + var $modal = $(qweb.render('website.gallery.slideshow.lightbox', { + srcs: urls, + index: idx, + 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/xml/website.gallery.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]'); + var nbPerPage = Math.floor(this.$indicator.width() / $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 (self.editableMode) { // do not remove DOM in edit mode + return; + } + if (page <= 0) { + self.$prev.detach(); + } else { + self.$prev.prependTo(self.$indicator); + } + if (page >= nbPages - 1) { + self.$next.detach(); + } else { + self.$next.appendTo(self.$indicator); + } + } + + function update() { + index = $lis.index($lis.filter('.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); + 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 00000000000..dce08072f5d --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/000.scss @@ -0,0 +1,156 @@ + +.o_gallery { + &.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; + } + > .container { + height: 100%; + } + } + .carousel-inner .item img { + max-width: none; + } +} +.o_gallery, +.modal-body { + &.o_slideshow { + .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; + margin: 2.5px 2.5px 2.5px 2.5px; + padding: 0; + 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); + } + } + } + } + } +} 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 00000000000..819ac1aedc0 --- /dev/null +++ b/addons/website/static/src/snippets/s_image_gallery/options.js @@ -0,0 +1,456 @@ +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/xml/website.gallery.xml'], + + /** + * @override + */ + start: function () { + var self = this; + + // The snippet should not be editable + this.$target.addClass('o_fake_not_editable').attr('contentEditable', false); + + // Make sure image previews are updated if images are changed + this.$target.on('save', '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'); + }); + }); + } + }); + + if (this.$('.container:first > *:not(div)').length) { + self.mode(null, self.getMode()); + } + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + var uuid = new Date().getTime(); + this.$target.find('.carousel').attr('id', 'slideshow_' + uuid); + this.$target.find('[data-target]').attr('data-target', '#slideshow_' + uuid); + }, + /** + * @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) { + var $container = this.$('.container:first'); + 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: 'img img-fluid', + src: attachments[i].image_src, + 'data-index': ++index, + }).appendTo($container); + } + this.mode('reset', this.getMode()); + this.trigger_up('cover_update'); + 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'}); + 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'}); + $row.appendTo($container); + } + }); + this.$target.css('height', ''); + }, + /** + * Allows to changes the interval of automatic slideshow (not active in + * edit mode). + */ + interval: function (previewMode, widgetValue) { + this.$target.find('.carousel:first').attr('data-interval', widgetValue || '0'); + }, + /** + * 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'}); + this._replaceContent($row); + + // Create columns + for (var c = 0; c < columns; c++) { + var $col = $('<div/>', {class: '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.pop()); + } + }, + /** + * 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[widgetValue](); + this.$target + .removeClass('o_nomode o_masonry o_grid o_slideshow') + .addClass('o_' + widgetValue); + this.trigger_up('cover_update'); + }, + /** + * Displays the images with the standard layout: floating images. + */ + nomode: function () { + var $row = $('<div/>', {class: 'row'}); + 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_editable_mode_display 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 () { + var imgStyle = this.$el.find('.active[data-styling]').data('styling') || ''; + var urls = _.map(this._getImages(), function (img) { + return $(img).attr('src'); + }); + var currentInterval = this.$target.find('.carousel:first').attr('data-interval'); + var params = { + srcs: urls, + index: 0, + title: "", + interval: currentInterval || this.$target.data('interval') || 0, + id: 'slideshow_' + new Date().getTime(), + userStyle: imgStyle, + }, + $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'); + }, + /** + * Allows to change the style of the individual images. + * + * @see this.selectClass for parameters + */ + styling: function (previewMode, value) { + var classes = _.map(this.$el.find('[data-styling]'), function (el) { + return $(el).data('styling'); + }).join(' '); + this.$('img').removeClass(classes).addClass(value); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Handles image removals and image index updates. + * + * @override + */ + notify: async function (name, data) { + await 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; + } + _.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); + }); + this.mode('reset', this.getMode()); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @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 'interval': { + const carousel = this.$target[0].querySelector('.carousel'); + return (carousel && carousel.dataset.interval || '0'); + } + case 'columns': { + return `${this._getColumns()}`; + } + case 'styling': { + const img = this.$target[0].querySelector('img'); + if (!img) { + return ''; + } + for (const className of params.possibleValues) { + if (className && img.classList.contains(className)) { + return className; + } + } + return ''; + } + } + 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:first'); + $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('deactivate_snippet'); + this.trigger_up('option_update', { + optionName: 'gallery', + name: 'image_index_request', + data: { + $image: this.$target, + position: widgetValue, + }, + }); + }, +}); +}); diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index 93c2532f29d..625fe454b0d 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -109,6 +109,7 @@ <script type="text/javascript" src="/website/static/src/js/editor/rte.summernote.js"/> <script type="text/javascript" src="/website/static/src/js/editor/snippets.options.js"/> <script type="text/javascript" src="/website/static/src/snippets/s_facebook_page/options.js"/> + <script type="text/javascript" src="/website/static/src/snippets/s_image_gallery/options.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/> diff --git a/addons/website/views/snippets/s_image_gallery.xml b/addons/website/views/snippets/s_image_gallery.xml new file mode 100644 index 00000000000..8d1a3190942 --- /dev/null +++ b/addons/website/views/snippets/s_image_gallery.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<template id="s_image_gallery" name="Image Gallery"> + <section class="o_gallery o_spc-medium o_slideshow s_image_gallery" data-columns="3" style="height: 500px; overflow: hidden;"> + <div class="container"> + <div class="alert alert-info css_editable_mode_display text-center" role="status"><span class="o_add_images" style="cursor: pointer;"><i class="fa fa-plus-circle"/> Add Images</span></div> + </div> + </section> +</template> + +<template id="s_image_gallery_options" inherit_id="website.snippet_options"> + <xpath expr="." position="inside"> + <div data-js="gallery" data-selector=".o_gallery"> + <we-button data-add-images="true" data-no-preview="true">Add images</we-button> + <we-button data-remove-all-images="true" data-no-preview="true">Remove all images</we-button> + <we-divider/> + <we-select string="Mode"> + <we-button data-mode="nomode">Float</we-button> + <we-button data-mode="masonry" data-name="masonry_mode_opt">Masonry</we-button> + <we-button data-mode="grid" data-name="grid_mode_opt">Grid</we-button> + <we-button data-mode="slideshow" data-name="slideshow_mode_opt">Slideshow</we-button> + </we-select> + <we-select string="Slideshow speed" data-dependencies="slideshow_mode_opt"> + <we-button data-interval="1000">1s</we-button> + <we-button data-interval="2000">2s</we-button> + <we-button data-interval="3000">3s</we-button> + <we-button data-interval="5000">5s</we-button> + <we-button data-interval="10000">10s</we-button> + <we-button data-interval="0">Disable autoplay</we-button> + </we-select> + <we-select string="Columns" data-dependencies="masonry_mode_opt, grid_mode_opt"> + <we-button data-columns="1">1</we-button> + <we-button data-columns="2">2</we-button> + <we-button data-columns="3">3</we-button> + <we-button data-columns="4">4</we-button> + <we-button data-columns="6">6</we-button> + <we-button data-columns="12">12</we-button> + </we-select> + <we-select string="Images spacing" data-dependencies="!slideshow_mode_opt"> + <we-button data-select-class="o_spc-none">None</we-button> + <we-button data-select-class="o_spc-small">Small</we-button> + <we-button data-select-class="o_spc-medium">Medium</we-button> + <we-button data-select-class="o_spc-big">Big</we-button> + </we-select> + <we-select string="Styling"> + <we-button data-styling="">Square</we-button> + <we-button data-styling="rounded">Rounded corners</we-button> + <we-button data-styling="img-thumbnail">Thumbnails</we-button> + <we-button data-styling="rounded-circle">Circle</we-button> + <we-button data-styling="shadow">Shadows</we-button> + </we-select> + </div> + <div data-js="gallery_img" data-selector=".o_gallery img"> + <div data-no-preview="true"> + <we-title>Re-order</we-title> + <we-button data-position="first">Move to first</we-button> + <we-button data-position="prev">Move to previous</we-button> + <we-button data-position="next">Move to next</we-button> + <we-button data-position="last">Move to last</we-button> + </div> + </div> + </xpath> +</template> + +<template id="assets_snippet_s_image_gallery_css_000" inherit_id="website.assets_frontend"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/website/static/src/snippets/s_image_gallery/000.scss"/> + </xpath> +</template> + +<template id="assets_snippet_s_image_gallery_js_000" inherit_id="website.assets_frontend"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website/static/src/snippets/s_image_gallery/000.js"/> + </xpath> +</template> + +</odoo> diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml new file mode 100644 index 00000000000..7e058da30f6 --- /dev/null +++ b/addons/website/views/snippets/snippets.xml @@ -0,0 +1,1071 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<template id="s_countdown" name="Countdown"> + <section class="s_countdown" + data-display="dhms" data-end-action="nothing" data-size="175" + t-att-data-end-time="datetime.datetime.now().timestamp() + 228307" + data-layout="circle" data-layout-background="none" + data-progress-bar-style="surrounded" data-progress-bar-weight="thin" + data-text-color="alpha" data-layout-background-color="400" data-progress-bar-color="alpha"> + <div class="container"> + <div class="s_countdown_canvas_wrapper text-center pt8 pb8"/> + </div> + </section> +</template> + +<template id="s_comparisons" name="Comparisons"> + <section class="s_comparisons pt48 pb24"> + <div class="container"> + <div class="row s_nb_column_fixed"> + <div class="col-lg-12 s_title pt16 pb16" style="text-align: center;"> + <h1 class="s_title_thin"><font style="font-size: 62px;">Our offers</font></h1> + </div> + </div> + <div class="row"> + <div class="col-lg-4 s_col_no_bgcolor text-center pt16 pb16" data-name="Box"> + <div class="card bg-200"> + <h4 class="card-header">Beginner</h4> + <div class="card-body text-center"> + <h2 class="card-title"> + <span class="s_comparisons_currency">$</span> + <span class="s_comparisons_price"><b>35</b></span> + <span class="s_comparisons_decimal">.00</span> + </h2> + <small>/ month</small> + </div> + <ul class="list-group list-group-flush"> + <li class="list-group-item">Basic sales & marketing for up to 2 users</li> + <li class="list-group-item">Account & Sales management</li> + <li class="list-group-item">No customization</li> + <li class="list-group-item">No support</li> + </ul> + <div class="card-footer"> + <p><i>Instant setup, satisfied or reimbursed.</i></p> + <a href="/contactus" class="btn btn-secondary">Order now</a> + </div> + </div> + </div> + <div class="col-lg-4 s_col_no_bgcolor text-center pt16 pb16" data-name="Box"> + <div class="card bg-primary"> + <h4 class="card-header">Professional</h4> + <div class="card-body"> + <h2 class="card-title"> + <span class="s_comparisons_currency">$</span> + <span class="s_comparisons_price"><b>65</b></span> + <span class="s_comparisons_decimal">.00</span> + </h2> + <small>/ month</small> + </div> + <ul class="list-group list-group-flush"> + <li class="list-group-item">Complete CRM for any size team</li> + <li class="list-group-item">Get access to all modules</li> + <li class="list-group-item">Limited customization</li> + <li class="list-group-item">Email support</li> + </ul> + <div class="card-footer"> + <p><i>Instant setup, satisfied or reimbursed.</i></p> + <a href="/contactus" class="btn btn-light">Start now</a> + </div> + </div> + </div> + <div class="col-lg-4 s_col_no_bgcolor text-center pt16 pb16" data-name="Box"> + <div class="card bg-secondary"> + <h4 class="card-header">Expert</h4> + <div class="card-body text-center"> + <h2 class="card-title"> + <span class="s_comparisons_currency">$</span> + <span class="s_comparisons_price"><b>125</b></span> + <span class="s_comparisons_decimal">.00</span> + </h2> + <small>/ month</small> + </div> + <ul class="list-group list-group-flush"> + <li class="list-group-item">Unlimited CRM power and support</li> + <li class="list-group-item">Get access to all modules and features</li> + <li class="list-group-item">Unlimited customization</li> + <li class="list-group-item">24x7 toll-free support</li> + </ul> + <div class="card-footer"> + <p><i>Instant setup, satisfied or reimbursed.</i></p> + <a href="/contactus" class="btn btn-light">Contact us</a> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<template id="s_company_team" name="Company Team"> + <section class="s_company_team"> + <div class="container"> + <h1 style="text-align: center;">Meet the Executive Team</h1> + <div class="row s_nb_column_fixed"> + <div class="col-lg-6 pt16 pb16"> + <div class="row s_col_no_resize s_col_no_bgcolor"> + <div class="col-lg-4 "> + <img alt="Company team" src="/web/image/website.s_company_team_image_1" class="img-fluid rounded-circle d-block mx-auto shadow"/> + </div> + <div class="col-lg-8"> + <h4>Tony Fred, CEO</h4> + <p class="text-muted"> + Founder and chief visionary, Tony is the driving force behind Company. He loves + to keep his hands full by participating in the development of the software, + marketing and the Customer Experience strategies. + </p> + </div> + </div> + </div> + <div class="col-lg-6 pt16 pb16"> + <div class="row s_col_no_resize s_col_no_bgcolor"> + <div class="col-lg-4"> + <img alt="Company team" src="/web/image/website.s_company_team_image_2" class="img-fluid rounded-circle d-block mx-auto shadow"/> + </div> + <div class="col-lg-8"> + <h4>Mich Stark, COO</h4> + <p class="text-muted"> + Mich loves taking on challenges. With his multi-year experience as Commercial + Director in the software industry, Mich has helped Company to get where it + is today. Mich is among the best minds. + </p> + </div> + </div> + </div> + <div class="col-lg-6 pt16 pb16"> + <div class="row s_col_no_resize s_col_no_bgcolor"> + <div class="col-lg-4"> + <img alt="Company team" src="/web/image/website.s_company_team_image_3" class="img-fluid rounded-circle d-block mx-auto shadow"/> + </div> + <div class="col-lg-8"> + <h4>Aline Turner, CTO</h4> + <p class="text-muted"> + Aline is one of the iconic person in life who can say she loves what she does. + She mentors 100+ in-house developers and looks after the community of over + thousands developers. + </p> + </div> + </div> + </div> + <div class="col-lg-6 pt16 pb16"> + <div class="row s_col_no_resize s_col_no_bgcolor"> + <div class="col-lg-4"> + <img alt="Company team" src="/web/image/website.s_company_team_image_4" class="img-fluid rounded-circle d-block mx-auto shadow"/> + </div> + <div class="col-lg-8"> + <h4>Iris Joe, CFO</h4> + <p class="text-muted"> + Iris, with her international experience, helps us easily understand the numbers and + improves them. She is determined to drive success and delivers her professional + acumen to bring Company at the next level. + </p> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<template id="s_call_to_action" name="Call to Action"> + <section class="s_call_to_action bg-200 pt48 pb24"> + <div class="container"> + <div class="row"> + <div class="col-lg-9 pb24"> + <h3><b>50,000+ companies</b> run Odoo to grow their businesses.</h3> + <p class="lead">Join us and make your company a better place.</p> + </div> + <div class="col-lg-3 s_btn text-right pt8 pb24"> + <a href="/contactus" class="btn btn-primary btn-lg"> + <span>Contact us</span> + <i class="fa fa-1x fa-fw fa-arrow-circle-right ml-1"/> + </a> + </div> + </div> + </div> + </section> +</template> + +<template id="s_references" name="References"> + <section class="s_references bg-200 pt16 pb16"> + <div class="container"> + <div class="row"> + <div class="col-lg-2 pt32"> + <h4>References</h4> + </div> + <div class="col-lg-2"> + <img src="/web/image/website.s_reference_demo_image_1" class="img img-fluid " alt="Demo Logo"/> + </div> + <div class="col-lg-2"> + <img src="/web/image/website.s_reference_demo_image_2" class="img img-fluid " alt="Demo Logo"/> + </div> + <div class="col-lg-2"> + <img src="/web/image/website.s_reference_demo_image_3" class="img img-fluid " alt="Demo Logo"/> + </div> + <div class="col-lg-2"> + <img src="/web/image/website.s_reference_demo_image_4" class="img img-fluid " alt="Demo Logo"/> + </div> + <div class="col-lg-2"> + <img src="/web/image/website.s_reference_demo_image_5" class="img img-fluid " alt="Demo Logo"/> + </div> + </div> + </div> + </section> +</template> + +<template id="s_faq_collapse" name="Accordion"> + <section class="s_faq_collapse pt48 pb32"> + <div class="container"> + <div class="row s_nb_column_fixed"> + <div class="col-lg-12 s_title pt16 pb16"> + <h1 class="s_title_thin"><font style="font-size: 62px;">FAQ</font></h1> + </div> + </div> + <div class="row s_col_no_bgcolor"> + <div class="col-lg-12 pt16 pb16"> + <div id="myCollapse" class="accordion" role="tablist"> + <div class="card bg-white"> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="true" class="card-header">Terms of service</a> + <div class="collapse show" role="tabpanel"> + <div class="card-body"> + <p class="card-text">These terms of service ("Terms", "Agreement") are an agreement between the website ("Website operator", "us", "we" or "our") and you ("User", "you" or "your"). This Agreement sets forth the general terms and conditions of your use of this website and any of its products or services (collectively, "Website" or "Services").</p> + </div> + </div> + </div> + <div class="card bg-white"> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header">Links to other Websites</a> + <div class="collapse" role="tabpanel"> + <div class="card-body"> + <p class="card-text">Although this Website may be linked to other websites, we are not, directly or indirectly, implying any approval, association, sponsorship, endorsement, or affiliation with any linked website, unless specifically stated herein.</p> + <p class="card-text">You should carefully review the legal statements and other conditions of use of any website which you access through a link from this Website. Your linking to any other off-site pages or other websites is at your own risk.</p> + </div> + </div> + </div> + <div class="card bg-white"> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header">Use of Cookies</a> + <div class="collapse" role="tabpanel"> + <div class="card-body"> + <p class="card-text">Website may use cookies to personalize and facilitate maximum navigation of the User by this site. The User may configure his / her browser to notify and reject the installation of the cookies sent by us.</p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<template id="s_features_grid" name="Features Grid"> + <section class="s_features_grid pt48 pb24"> + <div class="container"> + <div class="row"> + <div class="col-lg-6 s_col_no_bgcolor pb24"> + <div class="row"> + <div class="col-lg-12 pb24" data-name="Box"> + <h2>First list of Features</h2> + <h5 class="text-primary">Add a great slogan.</h5> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-font-awesome rounded-circle bg-primary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Change Icons</h4> + <p>Double click an icon to replace it with one of your choice.</p> + </div> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-files-o rounded-circle bg-primary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Duplicate</h4> + <p>Duplicate blocks and columns to add more features.</p> + </div> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-trash rounded-circle bg-primary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Delete Blocks</h4> + <p>Select and delete blocks to remove some features.</p> + </div> + </div> + </div> + </div> + <div class="col-lg-6 s_col_no_bgcolor pb24"> + <div class="row"> + <div class="col-lg-12 pb24" data-name="Box"> + <h2>Second list of Features</h2> + <h5 class="text-secondary">Add a great slogan.</h5> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-magic rounded bg-secondary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Great Value</h4> + <p>Turn every feature into a benefit for your reader.</p> + </div> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-eyedropper rounded bg-secondary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Edit Styles</h4> + <p>You can edit colors and background to highlight features.</p> + </div> + </div> + <div class="col-lg-12 pt16 pb16" data-name="Box"> + <i class="fa fa-2x fa-picture-o rounded bg-secondary s_features_grid_icon"/> + <div class="s_features_grid_content"> + <h4>Sample Icons</h4> + <p>All these icons are completely free for commercial use.</p> + </div> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<template id="s_tabs" name="Tabs"> + <section class="s_tabs"> + <div class="container"> + <div class="row s_col_no_bgcolor"> + <div class="col-lg-8 offset-lg-2 pt48 pb48"> + <div class="card bg-white"> + <div class="card-header"> + <ul class="nav nav-tabs card-header-tabs" role="tablist"> + <li class="nav-item"> + <a class="nav-link active" + id="nav_tabs_link_1" + data-toggle="tab" + href="#nav_tabs_content_1" + role="tab" + aria-controls="nav_tabs_content_1" + aria-selected="true">Home</a> + </li> + <li class="nav-item"> + <a class="nav-link" + id="nav_tabs_link_2" + data-toggle="tab" + href="#nav_tabs_content_2" + role="tab" + aria-controls="nav_tabs_content_2" + aria-selected="false">Profile</a> + </li> + <li class="nav-item"> + <a class="nav-link" + id="nav_tabs_link_3" + data-toggle="tab" + href="#nav_tabs_content_3" + role="tab" + aria-controls="nav_tabs_content_3" + aria-selected="false">Contact</a> + </li> + </ul> + </div> + <div class="card-body tab-content"> + <div class="tab-pane fade show active" + id="nav_tabs_content_1" + role="tabpanel" + aria-labelledby="nav_tabs_link_1"> + <h3>Details</h3> + <p><b>Great stories have personality.</b> Consider telling a great story that provides personality. Writing a story with personality for potential clients will assists with making a relationship connection. This shows up in small quirks like word choices or phrases. Write from your point of view, not from someone else's experience.</p> + </div> + <div class="tab-pane fade" + id="nav_tabs_content_2" + role="tabpanel" + aria-labelledby="nav_tabs_link_2"> + <h3>Shipping</h3> + <p><b>Great stories are for everyone even when only written for just one person.</b> If you try to write with a wide general audience in mind, your story will ring false and be bland. No one will be interested. Write for one person. If it’s genuine for the one, it’s genuine for the rest.</p> + </div> + <div class="tab-pane fade" + id="nav_tabs_content_3" + role="tabpanel" + aria-labelledby="nav_tabs_link_3"> + <h3>Review</h3> + <blockquote> + <p>Write a quote here from one of your customers. Quotes are a great way to build confidence in your products or services.</p> + <footer>— Jane DOE, CEO of <b>MyCompany</b></footer> + </blockquote> + </div> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<template id="s_chart" name="Chart"> + <div class="s_chart" data-type="bar" data-legend-position="top" data-tooltip-display="true" data-stacked="false" data-border-width="2" + data-data="{ + "labels":["First","Second","Third","Fourth","Fifth"], + "datasets":[ + { + "label":"One", + "data":["12","24","18","17","10"], + "backgroundColor":"alpha", + "borderColor":"alpha" + } + ] + }"> + <h2>A Chart Title</h2> + <canvas/> + </div> +</template> + +<template id="s_parallax" name="Parallax"> + <section class="s_parallax parallax s_parallax_is_fixed bg-black-50 pt160 pb160" data-scroll-background-ratio="1"> + <span class="s_parallax_bg oe_img_bg oe_custom_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 75%;"/> + <div class="oe_structure"> + <section> + <div class="container"> + <div class="row s_nb_column_fixed"> + <div class="col-lg-12 s_title pt16 pb16" style="text-align: center;"> + <h1 class="s_title_boxed"><font style="font-size: 62px;">Your Site Title</font></h1> + </div> + </div> + </div> + </section> + </div> + </section> +</template> + +<template id="s_quotes_carousel" name="Quotes"> + <section class="s_quotes_carousel_wrapper"> + <div id="myQuoteCarousel" class="s_quotes_carousel s_carousel_default carousel slide" data-interval="10000"> + <!-- Indicators --> + <ol class="carousel-indicators"> + <li data-target="#myQuoteCarousel" data-slide-to="0" class="active"></li> + <li data-target="#myQuoteCarousel" data-slide-to="1"></li> + <li data-target="#myQuoteCarousel" data-slide-to="2"></li> + </ol> + <!-- Content --> + <div class="carousel-inner"> + <!-- #01 --> + <div class="carousel-item active bg-200 pt80 pb80" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="carousel-content col-lg-6 bg-white offset-lg-3"> + <i class="fa fa-1x fa-quote-left rounded-0 bg-secondary s_quotes_carousel_icon"/> + <p>Write a quote here from one of your customers. Quotes are a great way to build confidence in your products or services.</p> + <footer> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_3" alt=""/> + <span>— Jane DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + <!-- #02 --> + <div class="carousel-item oe_img_bg pt80 pb80" style="background-image: url('/web/image/website.s_quotes_carousel_demo_image_1'); background-position: 50% 50%;" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="col-lg-6 bg-white"> + <p>Odoo provides essential platform for our project management. Things are better organized and more visible with it.</p> + <footer> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_4" alt=""/> + <span>— John DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + <!-- #03 --> + <div class="carousel-item oe_img_bg pt80 pb80" style="background-image: url('/web/image/website.s_quotes_carousel_demo_image_2'); background-position: 50% 50%;" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="col-lg-6 offset-lg-6 bg-white"> + <p>Odoo provides essential platform for our project management. Things are better organized and more visible with it.</p> + <footer class="blockquote-footer"> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_5" alt=""/> + <span>— Jane DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + </div> + <!-- Controls --> + <div class="carousel-control-prev" data-target="#myQuoteCarousel" data-slide="prev" role="img" aria-label="Previous" title="Previous"> + <span class="carousel-control-prev-icon"/> + <span class="sr-only">Previous</span> + </div> + <div class="carousel-control-next" data-target="#myQuoteCarousel" data-slide="next" role="img" aria-label="Next" title="Next"> + <span class="carousel-control-next-icon"/> + <span class="sr-only">Next</span> + </div> + </div> + </section> +</template> + +<!-- Mega menu snippets --> + +<template id="s_mega_menu_multi_menus" name="Multi-Menus"> + <section class="s_mega_menu_multi_menus py-4"> + <div class="container"> + <div class="row"> + <t t-set="menu1_title">First Menu</t> + <t t-set="menu2_title">Second Menu</t> + <t t-set="menu3_title">Third Menu</t> + <t t-set="menu4_title">Last Menu</t> + <t t-foreach="[menu1_title, menu2_title, menu3_title, menu4_title]" t-as="menu_title"> + <div class="col-lg-3 py-2 text-center"> + <h4 t-esc="menu_title"/> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + </t> + </div> + </div> + </section> +</template> + +<template id="s_mega_menu_menu_image_menu" name="Menu - Image - Menu"> + <section class="s_mega_menu_menu_image_menu py-4"> + <div class="container"> + <div class="row align-items-center"> + <div class="col-lg-4 py-2 text-center"> + <h4>Left Menu</h4> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + <div class="col-lg-4 py-2 text-center"> + <img class="img-fluid" src="/web/image/website.s_mega_menu_menu_image_menu_default_image"/> + </div> + <div class="col-lg-4 py-2 text-center"> + <h4>Right Menu</h4> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + </div> + </div> + </section> +</template> + +<!-- Snippets menu --> +<template id="snippets" inherit_id="web_editor.snippets" primary="True"> + <xpath expr="//div[@id='o_scroll']" position="replace"> + <div id="o_scroll"> + <div id="snippet_mega_menu" class="o_panel d-none"> + <div class="o_panel_header"> + <i class="fa fa-puzzle-piece"/> Mega Menu + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_mega_menu_multi_menus" t-thumbnail="/website/static/src/img/snippets_thumbs/s_mega_menu_multi_menus.png"/> + <t t-snippet="website.s_mega_menu_menu_image_menu" t-thumbnail="/website/static/src/img/snippets_thumbs/s_mega_menu_menu_image_menu.png"/> + </div> + </div> + + <div id="snippet_structure" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-th-large"/> Structure + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_banner" t-thumbnail="/website/static/src/img/snippets_thumbs/s_banner.png"/> + <t t-snippet="website.s_cover" t-thumbnail="/website/static/src/img/snippets_thumbs/s_cover.png"/> + <t t-snippet="website.s_text_image" t-thumbnail="/website/static/src/img/snippets_thumbs/s_text_image.png"/> + <t t-snippet="website.s_image_text" t-thumbnail="/website/static/src/img/snippets_thumbs/s_image_text.png"/> + <t t-snippet="website.s_title" t-thumbnail="/website/static/src/img/snippets_thumbs/s_title.png"/> + <t t-snippet="website.s_text_block" t-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png"/> + <t t-snippet="website.s_picture" t-thumbnail="/website/static/src/img/snippets_thumbs/s_picture.png"/> + <t t-snippet="website.s_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_carousel.png"/> + <t t-snippet="website.s_features" t-thumbnail="/website/static/src/img/snippets_thumbs/s_features.png"/> + <t t-snippet="website.s_three_columns" t-thumbnail="/website/static/src/img/snippets_thumbs/s_three_columns.png"/> + </div> + </div> + + <div id="snippet_feature" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-diamond"/> Features + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_image_gallery" t-thumbnail="/website/static/src/img/snippets_thumbs/s_image_gallery.png"/> + <t t-snippet="website.s_comparisons" t-thumbnail="/website/static/src/img/snippets_thumbs/s_comparison.png"/> + <t t-snippet="website.s_company_team" t-thumbnail="/website/static/src/img/snippets_thumbs/s_company_team.png"/> + <t t-snippet="website.s_call_to_action" t-thumbnail="/website/static/src/img/snippets_thumbs/s_call_to_action.png"/> + <t t-snippet="website.s_references" t-thumbnail="/website/static/src/img/snippets_thumbs/s_references.png"/> + <t t-snippet="website.s_faq_collapse" t-thumbnail="/website/static/src/img/snippets_thumbs/s_faq.png"/> + <t t-snippet="website.s_features_grid" t-thumbnail="/website/static/src/img/snippets_thumbs/s_features_grid.png"/> + <t t-snippet="website.s_tabs" t-thumbnail="/website/static/src/img/snippets_thumbs/s_tabs.png"/> + <t t-snippet="website.s_countdown" t-thumbnail="/website/static/src/img/snippets_thumbs/s_countdown.png"/> + </div> + </div> + + <div id="snippet_effect" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-magic icon-fix"/> Effects + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_parallax" t-thumbnail="/website/static/src/img/snippets_thumbs/s_parallax.png"/> + <t t-snippet="website.s_quotes_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_quotes_carousel.png"/> + </div> + </div> + + <div id="snippet_content" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-indent"/> Inner content + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_btn" t-thumbnail="/website/static/src/img/snippets_thumbs/s_button.png"/> + <t t-snippet="website.s_hr" t-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png"/> + <t t-snippet="website.s_alert" t-thumbnail="/website/static/src/img/snippets_thumbs/s_alert.png"/> + <t t-snippet="website.s_card" t-thumbnail="/website/static/src/img/snippets_thumbs/s_card.png"/> + <t t-snippet="website.s_share" t-thumbnail="/website/static/src/img/snippets_thumbs/s_share.png"/> + <t t-snippet="website.s_facebook_page" t-thumbnail="/website/static/src/img/snippets_thumbs/s_facebook_page.png"/> + <t t-snippet="website.s_rating" t-thumbnail="/website/static/src/img/snippets_thumbs/s_rating.png"/> + <t t-snippet="website.s_chart" t-thumbnail="/website/static/src/img/snippets_thumbs/s_chart.png"/> + </div> + </div> + </div> + </xpath> + + <xpath expr="//div[@id='snippet_options']/t" position="attributes"> + <attribute name="t-call">website.snippet_options</attribute> + </xpath> +</template> + +<template id="external_snippets" inherit_id="website.snippets" priority="8"> + <xpath expr="//div[@id='snippet_feature']//t[@t-snippet][last()]" position="after"> + <t id="newsletter_popup_snippet" t-install="mass_mailing" string="Newsletter Popup" t-thumbnail="/website/static/src/img/snippets_thumbs/newsletter_subscribe_popup.png"/> + <t id="newsletter_block_snippet" t-install="mass_mailing" string="Newsletter Block" t-thumbnail="/website/static/src/img/snippets_thumbs/s_newsletter_block.png"/> + <t t-install="website_twitter" string="Twitter Scroller" t-thumbnail="/website/static/src/img/snippets_thumbs/s_twitter_scroll.png"/> + <t t-install="website_form" string="Form Builder" t-thumbnail="/website/static/src/img/s_website_form.png"/> + <t id="s_products_searchbar" t-install="website_sale" string="Products Search" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/> + <t id="s_products_recently_viewed" t-install="website_sale" string="Products Recently Viewed" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png"/> + </xpath> + <xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after"> + <t t-install="website_event" string="Local Events" t-thumbnail="/website/static/src/img/snippets_thumbs/s_local_events.png"/> + <t t-install="website_mail_channel" string="Discussion Group" t-thumbnail="/website/static/src/img/snippets_thumbs/s_button_channel_subscribe.png"/> + <t id="newsletter_snippet" t-install="mass_mailing" string="Newsletter" t-thumbnail="/website/static/src/img/snippets_thumbs/s_newsletter_subscribe_form.png"/> + <t id="s_products_searchbar_input" t-install="website_sale" string="Products Search Input" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/> + </xpath> +</template> + +<template id="snippet_options"> + <t t-call="web_editor.snippet_options"/> + + <!-- COLOR | .s_three_columns | .s_comparisons --> + <div data-selector=".s_three_columns .row > div, .s_comparisons .row > div, .s_tabs .row > div" + data-target=".card"> + <we-colorpicker string="Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- COLOR | .s_cards --> + <div data-selector=".s_card, .accordion .card"> + <we-colorpicker string="Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- H-ALIGN | .s_btn --> + <div data-selector=".s_btn, .s_share"> + <we-select string="Alignment"> + <we-button data-select-class="text-left">Left</we-button> + <we-button data-select-class="text-center">Center</we-button> + <we-button data-select-class="text-right">Right</we-button> + </we-select> + </div> + + <div data-selector=".s_alert, .s_card"> + <we-select string="Width"> + <we-button data-select-class="w-25">25%</we-button> + <we-button data-select-class="w-50">50%</we-button> + <we-button data-select-class="w-75">75%</we-button> + <we-button data-select-class="w-100">100%</we-button> + </we-select> + </div> + + <!-- Carousel | .s_carousel | .s_quotes_carousel --> + <div data-js="Carousel" + data-selector="section" + data-target="> .carousel"> + <we-select string="Transition"> + <we-button data-select-class="slide">Slide</we-button> + <we-button data-select-class="carousel-fade slide">Fade</we-button> + <we-divider/> + <we-button data-select-class="">None</we-button> + </we-select> + <we-select string="Style"> + <we-button data-select-class="s_carousel_bordered">Bordered</we-button> + <we-button data-select-class="s_carousel_boxed">Boxed</we-button> + <we-button data-select-class="s_carousel_rounded">Rounded</we-button> + <we-divider/> + <we-button data-select-class="s_carousel_default">Default</we-button> + </we-select> + <we-input string="Speed" class="o_we_inline" + data-select-data-attribute="0s" data-attribute-name="interval" + data-unit="s" data-save-unit="ms" data-step="0.1"/> + </div> + + <div data-js="CarouselItem" + data-selector=".carousel-item"> + <we-button data-slide="left" data-no-preview="true" class="fa fa-fw fa-angle-left"/> + <we-button data-slide="right" data-no-preview="true" class="fa fa-fw fa-angle-right"/> + <we-button data-add-slide="true" data-no-preview="true" class="fa fa-fw fa-plus"/> + <we-button data-remove-slide="true" data-no-preview="true" class="fa fa-fw fa-minus"/> + </div> + + <div data-js="navTabs" data-selector="section .row > div" data-target=".nav-tabs"> + <we-button data-add-tab="1" data-no-preview="true">Add Tab</we-button> + <we-button data-remove-tab="1" data-name="remove_tab_opt" data-no-preview="true">Remove Tab</we-button> + </div> + + <!-- Accordion --> + <div data-js="collapse" + data-selector='.accordion > .card' + data-drop-in='.accordion:has(> .card)'/> + + <div data-js="layout_column" + data-selector="section" + data-target="> * > .row:not(.s_nb_column_fixed)"> + <we-select string="Number of columns" data-no-preview="true"> + <we-button data-select-count="1">1</we-button> + <we-button data-select-count="2">2</we-button> + <we-button data-select-count="3">3</we-button> + <we-button data-select-count="4">4</we-button> + <we-button data-select-count="5">5</we-button> + <we-button data-select-count="6">6</we-button> + </we-select> + </div> + + <!-- Move snippets around --> + <div data-js="SnippetMove" + data-selector="section"> + <we-button class="fa fa-fw fa-angle-up" data-move-snippet="prev" data-no-preview="true"/> + <we-button class="fa fa-fw fa-angle-down" data-move-snippet="next" data-no-preview="true"/> + </div> + <div data-js="SnippetMove" + data-selector=".row:not(.s_col_no_resize) > div"> + <we-button class="fa fa-fw fa-angle-left" data-move-snippet="prev" data-no-preview="true"/> + <we-button class="fa fa-fw fa-angle-right" data-move-snippet="next" data-no-preview="true"/> + </div> + + <!-- V-ALIGN --> + <div id="row_valign_snippet_option" data-selector=".s_text_image, .s_image_text, .s_three_columns" data-target=".row"> + <we-select string="Alignment"> + <we-button data-select-class="align-items-start">Top</we-button> + <we-button data-select-class="align-items-center">Middle</we-button> + <we-button data-select-class="align-items-end">Bottom</we-button> + <we-divider/> + <we-button data-select-class="align-items-stretch">Equal height</we-button> + </we-select> + </div> + + <!-- Background Image --> + <div data-js="background" string="Background" + data-selector="section, .parallax, .carousel-item" + data-exclude=".s_hr, .s_image_gallery, .o_gallery .carousel-item"/> + + <!-- Background Image Size/Position --> + <div data-js="BackgroundPosition" + data-selector="section, .parallax, .carousel-item" + data-exclude=".o_gallery .carousel-item"> + <we-row> + <we-select data-no-preview="true"> + <we-button data-background-type="cover">Cover</we-button> + <we-button data-background-type="repeat-pattern" data-name="background_repeat_opt">Repeat pattern</we-button> + </we-select> + <we-button class="fa fa-fw fa-arrows" title="Background Position" + data-background-position-overlay="true" data-no-preview="true"/> + </we-row> + <we-multi data-css-property="background-size" data-dependencies="background_repeat_opt"> + <we-input string="Width" data-select-style="auto" data-unit="px"/> + <we-input string="Height" data-select-style="auto" data-unit="px"/> + </we-multi> + </div> + + <!-- Parallax --> + <div data-js="parallax" data-selector=".parallax"> + <we-select string="Scroll Speed" data-attribute-name="scrollBackgroundRatio" data-attribute-default-value="0"> + <we-button data-select-data-attribute="0">No-scroll</we-button> + <we-divider/> + <we-button data-select-data-attribute="1">Fixed</we-button> + <we-divider/> + <we-button data-select-data-attribute="0.6">Very Slow</we-button> + <we-button data-select-data-attribute="1.2">Slow</we-button> + <we-button data-select-data-attribute="1.6">Fast</we-button> + <we-button data-select-data-attribute="2">Very Fast</we-button> + </we-select> + </div> + + <!-- Color | Section --> + <div id="so_main_colorpicker" + data-selector="section, .carousel-item" + data-exclude=".parallax, .o_gallery .carousel-item"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- FILTER | .s_parallax --> + <div data-selector=".parallax"> + <we-colorpicker string="Filter" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-" + data-excluded="common, theme, custom"/> + </div> + + <!-- Color | Columns --> + <div data-selector="section .row > div" + data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <div data-selector="section .row > div" + data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div"> + <we-row string="Border" class="o_we_inline"> + <we-colorpicker data-dependencies="border_width_opt" + data-select-style="true" data-css-property="border-color" data-color-prefix="border-"/> + <we-input class="o_we_inline" data-name="border_width_opt" + data-select-style="0" data-css-property="border-width" + data-unit="px" data-extra-class="border"/> + </we-row> + <we-input string="Rounded" class="o_we_inline" + data-dependencies="border_width_opt" + data-select-style="0" data-css-property="border-radius" + data-unit="px" data-extra-class="rounded"/> + <we-checkbox string="Shadow" data-select-class="shadow"/> + </div> + + <div data-js="sizing_y" + data-selector="section, .row > div, .parallax, .s_hr, .s_btn, .carousel-item" + data-exclude="section:has(> .carousel), .o_gallery .carousel-item"/> + + <div data-js="sizing_x" + data-selector=".row > div" + data-drop-near=".row > div" + data-exclude=".s_col_no_resize.row > div"/> + + <div id="so_snippet_addition" + data-selector="section, .parallax" + data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section, > div))"/> + + <!-- Main content drop --> + <div id="so_content_addition" + data-selector="blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_countdown, .s_chart" + data-drop-near="p, h1, h2, h3, blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_chart" + data-drop-in=".content, nav"/> + + <div data-js="ul" + data-selector=":not(li) > ul:has(ul,ol), :not(li) > ol:has(ul,ol)"> + <we-checkbox string="Folded list" data-select-class="o_ul_folded"/> + </div> + + <div data-js="menu_data" + data-selector="#top_menu li > a" + data-exclude=".dropdown-toggle" + data-no-check="true"/> + + <div data-js="company_data" + data-selector="[data-oe-expression='res_company.partner_id']" + data-no-check="true"/> + + <div data-js="topMenuTransparency" + data-selector="[data-main-object^='website.page('] #wrapwrap > header" + data-no-check="true"> + <we-checkbox string="Transparent" data-transparent="true" data-no-preview="true"/> + </div> + + <div data-js="topMenuColor" + data-selector="[data-main-object^='website.page('] #wrapwrap > header" + data-no-check="true"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-" + data-excluded="theme, common, custom"/> + </div> + + <!-- Anchor Name --> + <div data-js="anchor" + data-selector=":not(p).oe_structure > *, :not(p)[data-oe-type=html] > *" + data-exclude=".modal *"> + <we-button class="fa fa-fw fa-link" + title="Create a link to target this section" + data-no-preview="true"/> + </div> + + <!-- Mega Menu settings --> + <div data-selector=".o_mega_menu"> + <we-select string="Size"> + <we-button data-select-class="">Full-Width</we-button> + <we-button data-select-class="o_mega_menu_container_size">Narrow</we-button> + </we-select> + </div> + + <div data-selector=".o_mega_menu .nav-link" + data-drop-in=".o_mega_menu nav" + data-drop-near=".o_mega_menu .nav-link"/> + + <div data-js="CoverProperties" data-selector=".o_record_cover_container" data-no-check="true"> + <we-button data-change="true" data-no-preview="true">Change Cover</we-button> + <we-button data-clear="true" data-no-preview="true" data-name="remove_cover_opt">Remove Cover</we-button> + <we-select string="Size" data-cover-opt-name="size"> + <we-button data-select-class="o_record_has_cover cover_full">Full Screen</we-button> + <we-button class="o_record_cover_opt_size_default" data-select-class="o_record_has_cover cover_mid">Half Screen</we-button> + <we-button data-select-class="o_record_has_cover cover_auto">Fit text</we-button> + </we-select> + <we-select string="Filter Intensity" data-cover-opt-name="filters"> + <we-button data-filter-value="0.0">None</we-button> + <we-button data-filter-value="0.2">Low</we-button> + <we-button data-filter-value="0.4">Medium</we-button> + <we-button data-filter-value="0.6">High</we-button> + </we-select> + <we-select string="Filter Color" data-cover-opt-name="filters"> + <we-button data-filter-color="oe_black">Black</we-button> + <we-button data-filter-color="oe_none">White</we-button> + <we-button data-filter-color="bg-primary">Primary</we-button> + <we-button data-filter-color="bg-secondary">Secondary</we-button> + <we-button data-filter-color="oe_blue">Blue</we-button> + <we-button data-filter-color="oe_yellow">Yellow</we-button> + <we-button data-filter-color="oe_red">Red</we-button> + <we-button data-filter-color="oe_purple">Purple</we-button> + <we-button data-filter-color="oe_green">Green</we-button> + </we-select> + <we-select string="Text Size" data-cover-opt-name="text_size"> + <we-button data-select-class="o_record_cover_font_hero">Hero</we-button> + <we-button data-select-class="o_record_cover_font_huge">Huge</we-button> + <we-button data-select-class="o_record_cover_font_big">Big</we-button> + <we-button data-select-class="">Regular</we-button> + <we-button data-select-class="o_record_cover_font_small">Small</we-button> + <we-button data-select-class="o_record_cover_font_tiny">Tiny</we-button> + </we-select> + <we-select string="Text Alignment" data-cover-opt-name="text_align"> + <we-button data-select-class="">Left</we-button> + <we-button data-select-class="text-center">Centered</we-button> + <we-button data-select-class="text-right">Right</we-button> + </we-select> + </div> + + <!-- Stretch section --> + <div data-js="SectionStretch" + data-selector="section" + data-target="> .container, > .container-fluid"> + <we-checkbox string="Stretch" data-select-class="container|container-fluid" data-no-preview="true"/> + </div> + + <!-- DISPLAY | .s_countdown --> + <div data-js="countdown" data-selector=".s_countdown"> + <we-datetimepicker string="Due Date" data-select-data-attribute="" data-attribute-name="endTime"/> + <we-row> + <we-select string="What should happen at the end?" data-no-preview="true"> + <we-button data-end-action="nothing" data-name="no_end_action_opt">Nothing</we-button> + <we-button data-end-action="redirect" data-name="redirect_end_action_opt">Redirect</we-button> + <we-button data-end-action="message">Show Message</we-button> + </we-select> + <we-button class="far fa-edit fa-fw align-self-end toggle-edit-message" title="Edit Message"/> + </we-row> + <we-checkbox string="Hide countdown at the end" data-dependencies="!no_end_action_opt" + data-select-class="hide-countdown"/> + <we-urlpicker string="URL" data-dependencies="redirect_end_action_opt" + data-select-data-attribute="/my-awesome-page" data-attribute-name="redirectUrl"/> + <we-select string="Size" data-attribute-name="size"> + <we-button data-select-data-attribute="80">Small</we-button> + <we-button data-select-data-attribute="120">Medium</we-button> + <we-button data-select-data-attribute="175">Large</we-button> + </we-select> + <we-select string="Display" data-attribute-name="display"> + <we-button data-select-data-attribute="d">D</we-button> + <we-button data-select-data-attribute="dhm">D - H - M</we-button> + <we-button data-select-data-attribute="dhms">D - H - M - S</we-button> + </we-select> + <we-colorpicker string="Text Color" data-attribute-name="textColor" data-select-data-attribute=""/> + <we-select string="Layout"> + <we-button data-layout="circle" data-name="circle_layout_opt">Circle</we-button> + <we-button data-layout="boxes" data-name="boxes_layout_opt">Boxes</we-button> + <we-button data-layout="clean">Clean</we-button> + <we-button data-layout="text">Text Inline</we-button> + </we-select> + <we-select string="Layout Background" data-attribute-name="layoutBackground" data-dependencies="circle_layout_opt, boxes_layout_opt"> + <we-button data-select-data-attribute="inner">Inner</we-button> + <we-button data-select-data-attribute="plain">Plain</we-button> + <we-button data-select-data-attribute="none" data-name="no_background_layout_opt">None</we-button> + </we-select> + <we-colorpicker string="Layout Background Color" data-dependencies="!no_background_layout_opt" + data-attribute-name="layoutBackgroundColor" data-select-data-attribute=""/> + <we-select string="Progress Bar Style" data-attribute-name="progressBarStyle" data-dependencies="circle_layout_opt, boxes_layout_opt"> + <we-button data-select-data-attribute="surrounded">Surrounded</we-button> + <we-button data-select-data-attribute="disappear">Disappearing</we-button> + <we-button data-select-data-attribute="none" data-name="no_progressbar_style_opt">None</we-button> + </we-select> + <we-select string="Progress Bar Weight" data-attribute-name="progressBarWeight" data-dependencies="!no_progressbar_style_opt"> + <we-button data-select-data-attribute="thin">Thin</we-button> + <we-button data-select-data-attribute="thick">Thick</we-button> + </we-select> + <we-colorpicker string="Progress Bar Color" data-dependencies="!no_progressbar_style_opt" + data-attribute-name="progressBarColor" data-select-data-attribute=""/> + </div> + + <!-- Chart Inner --> + <div data-js="InnerChart" string="Chart" + data-selector=".s_chart"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + <we-select string="Type" data-attribute-name="type" data-attribute-default-value="bar" data-no-preview="true"> + <we-button data-select-data-attribute="bar" data-name="bar_chart_opt">Bar Vertical</we-button> + <we-button data-select-data-attribute="horizontalBar" data-name="horizontal_bar_chart_opt">Bar Horizontal</we-button> + <we-button data-select-data-attribute="line">Line</we-button> + <we-button data-select-data-attribute="pie">Pie</we-button> + <we-button data-select-data-attribute="doughnut">Doughnut</we-button> + <we-button data-select-data-attribute="radar">Radar</we-button> + </we-select> + <we-checkbox string="Stacked" data-name="stacked_chart_opt" data-dependencies="bar_chart_opt, horizontal_bar_chart_opt" + data-select-data-attribute="false|true" data-attribute-name="stacked" data-no-preview="true"/> + <we-select string="Legend" data-attribute-name="legendPosition" data-attribute-default-value="top" data-no-preview="true"> + <we-button data-select-data-attribute="none">None</we-button> + <we-button data-select-data-attribute="top">Top</we-button> + <we-button data-select-data-attribute="left">Left</we-button> + <we-button data-select-data-attribute="bottom">Bottom</we-button> + <we-button data-select-data-attribute="right">Right</we-button> + </we-select> + <we-checkbox string="Tooltip" data-select-data-attribute="false|true" data-attribute-name="tooltipDisplay" data-no-preview="true"/> + <we-matrix> + <table> + <tr> + <th/> + <th><we-button class="fa fa-fw fa-plus add_column"/></th> + </tr> + <tr> + <th><we-button class="fa fa-fw fa-plus add_row"/></th> + </tr> + </table> + </we-matrix> + <we-colorpicker string="Background" data-name="chart_bg_color_opt" + data-color-change="" + data-attribute-name="backgroundColor" + data-no-preview="true"/> + <we-colorpicker string="Border" data-name="chart_border_color_opt" + data-color-change="" + data-attribute-name="borderColor" + data-no-preview="true"/> + <we-input class="o_we_inline" string="Border Width" data-select-data-attribute="2px" data-attribute-name="borderWidth" data-unit="px"/> + </div> +</template> +</odoo> |
