diff --git a/README.md b/README.md index 545c45b8d..a5eab05a3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ Please follow the following guides and code standards: The CMS uses [Laravel](http://laravel.com) as a foundation PHP framework. +### Using LESS + +The theme is based on the Twitter Bootstrap framework and uses LESS language for generating the CSS. Please use a suitable LESS application for compiling the **assets/less/*.less** file to **assets/css/*.css**. The following applications are recommended: + +* [LESS.app](http://incident57.com/less/) for Mac +* [WinLess](http://winless.org/) for Windows + ### Contact You can communicate with us using the following mediums: diff --git a/modules/backend/ServiceProvider.php b/modules/backend/ServiceProvider.php index 5bed5d86b..0ee23b327 100644 --- a/modules/backend/ServiceProvider.php +++ b/modules/backend/ServiceProvider.php @@ -121,8 +121,8 @@ class ServiceProvider extends ModuleServiceProvider */ BackendAuth::registerCallback(function($manager) { $manager->registerPermissions('October.Backend', [ - 'backend.access_dashboard' => ['label' => 'View the dashboard', 'tab' => 'System'], - 'backend.manage_users' => ['label' => 'Manage other administrators', 'tab' => 'System'], + 'backend.access_dashboard' => ['label' => 'system::lang.permissions.view_the_dashboard', 'tab' => 'System'], + 'backend.manage_users' => ['label' => 'system::lang.permissions.manage_other_administrators', 'tab' => 'System'], ]); }); diff --git a/modules/backend/assets/css/october.css b/modules/backend/assets/css/october.css index 3b7b92a5e..036561942 100644 --- a/modules/backend/assets/css/october.css +++ b/modules/backend/assets/css/october.css @@ -6613,13 +6613,20 @@ a .icon-flip-vertical:before { } [class^="flag-"], [class*=" flag-"] { - background: url(../images/flag-icons-small.png) no-repeat; + background-image: url("../images/flag-icons-small.png"); width: 16px; height: 16px; line-height: 16px; vertical-align: middle; display: inline-block; - margin: -1px 2px 0 2px; + margin: -3px 2px 0 2px; +} +@media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), only screen and (min-device-pixel-ratio: 2), only screen and (min-resolution: 192dpi), only screen and (min-resolution: 2dppx) { + [class^="flag-"], + [class*=" flag-"] { + background-image: url("../images/flag-icons-large.png"); + background-size: 16px 3952px; + } } .flag-AfricanUnion { background-position: 0 -16px; @@ -7490,6 +7497,278 @@ a .icon-flip-vertical:before { font-style: italic; font-weight: 600; } +@-webkit-keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.fadeIn { + -webkit-animation-name: fadeIn; + animation-name: fadeIn; +} +@-webkit-keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} +@keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} +.fadeInDown { + -webkit-animation-name: fadeInDown; + animation-name: fadeInDown; +} +@-webkit-keyframes fadeInLeft { + 0% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} +@keyframes fadeInLeft { + 0% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} +.fadeInLeft { + -webkit-animation-name: fadeInLeft; + animation-name: fadeInLeft; +} +@-webkit-keyframes fadeInRight { + 0% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} +@keyframes fadeInRight { + 0% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} +.fadeInRight { + -webkit-animation-name: fadeInRight; + animation-name: fadeInRight; +} +@-webkit-keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} +@keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} +.fadeInUp { + -webkit-animation-name: fadeInUp; + animation-name: fadeInUp; +} +@-webkit-keyframes fadeInUpBig { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 2000px, 0); + transform: translate3d(0, 2000px, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} +@-webkit-keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +.fadeOut { + -webkit-animation-name: fadeOut; + animation-name: fadeOut; +} +@-webkit-keyframes fadeOutDown { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} +@keyframes fadeOutDown { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} +.fadeOutDown { + -webkit-animation-name: fadeOutDown; + animation-name: fadeOutDown; +} +@-webkit-keyframes fadeOutLeft { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} +@keyframes fadeOutLeft { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} +.fadeOutLeft { + -webkit-animation-name: fadeOutLeft; + animation-name: fadeOutLeft; +} +@-webkit-keyframes fadeOutRight { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} +@keyframes fadeOutRight { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} +.fadeOutRight { + -webkit-animation-name: fadeOutRight; + animation-name: fadeOutRight; +} +@-webkit-keyframes fadeOutUp { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} +@keyframes fadeOutUp { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} +.fadeOutUp { + -webkit-animation-name: fadeOutUp; + animation-name: fadeOutUp; +} body.drag * { cursor: drag !important; cursor: -webkit-grab !important; @@ -9592,17 +9871,19 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { border-bottom: 1px solid #949ea6; } .control-filter a { - color: #949ea6; text-decoration: none; + color: #949ea6; } -.control-filter > .filter-set { +.control-filter > .filter-scope { display: inline-block; padding: 15px; } -.control-filter > .filter-set .filter-setting { +.control-filter > .filter-scope .filter-setting { display: inline-block; + -webkit-transition: color 0.6s; + transition: color 0.6s; } -.control-filter > .filter-set:after { +.control-filter > .filter-scope:after { font-size: 14px; font-family: FontAwesome; font-weight: normal; @@ -9612,7 +9893,7 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { *margin-right: .3em; content: "\f107"; } -.control-filter > .filter-set.active .filter-setting { +.control-filter > .filter-scope.active .filter-setting { padding-left: 5px; padding-right: 5px; color: #FFF; @@ -9620,14 +9901,26 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; + -webkit-transition: color 1s, background-color 1s; + transition: color 1s, background-color 1s; } -.control-filter > .filter-set:hover { +.control-filter > .filter-scope.checkbox { + padding-left: 35px; +} +.control-filter > .filter-scope.checkbox, +.control-filter > .filter-scope.checkbox label { + margin-bottom: 0; +} +.control-filter > .filter-scope.checkbox:after { + content: ''; +} +.control-filter > .filter-scope:hover { color: #000; } -.control-filter > .filter-set:hover .filter-label { +.control-filter > .filter-scope:hover .filter-label { color: #949ea6; } -.control-filter > .filter-set:hover.active .filter-setting { +.control-filter > .filter-scope:hover.active .filter-setting { background-color: #b32d00; } .control-filter-popover { @@ -9640,10 +9933,15 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { min-height: 36px; border: none; border-bottom: 1px solid #949ea6; - background: transparent url(../images/bitmap-icons.png) no-repeat 100% -82px; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + background-color: transparent; +} +.control-filter-popover .filter-search .form-control.icon.search { + background-position: right -81px; +} +.control-filter-popover .filter-search .close { + display: none; } .control-filter-popover .filter-items, .control-filter-popover .filter-active-items { @@ -9658,6 +9956,11 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { margin: 0; padding: 0; } +.control-filter-popover .filter-items li, +.control-filter-popover .filter-active-items li { + -webkit-transition: color 0.6s, background-color 0.3s; + transition: color 0.6s, background-color 0.3s; +} .control-filter-popover .filter-items a, .control-filter-popover .filter-active-items a { text-decoration: none; @@ -9677,6 +9980,8 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { color: #FFFFFF; } .control-filter-popover .filter-items { + height: 100px; + overflow: auto; background-color: #fafafa; border-bottom: 1px solid #949ea6; } @@ -9689,6 +9994,23 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { *margin-right: .3em; content: "\f067"; } +.control-filter-popover .filter-items li.loading { + padding: 7px; +} +.control-filter-popover .filter-items li.loading > span { + display: block; + height: 20px; + width: 20px; + background-image: url(../images/loading-indicator.svg); + background-size: 20px 20px; + background-position: 50% 50%; + -webkit-animation: spin 1s linear infinite; + animation: spin 1s linear infinite; +} +.control-filter-popover .filter-items li.animate-enter { + -webkit-animation: fadeInUp 0.5s; + animation: fadeInUp 0.5s; +} .control-filter-popover .filter-active-items a:before { font-family: FontAwesome; font-weight: normal; @@ -9698,6 +10020,31 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { *margin-right: .3em; content: "\f00d"; } +.control-filter-popover .filter-active-items li.animate-enter { + -webkit-animation: fadeInDown 0.5s; + animation: fadeInDown 0.5s; +} +@media (max-width: 480px) { + .control-filter-popover .filter-items { + height: 200px; + } + .control-filter-popover .filter-search input { + padding-left: 36px; + padding-right: 36px; + } + .control-filter-popover .filter-search .form-control.icon.search { + background-position: 0 -81px; + } + .control-filter-popover .filter-search .close { + width: 30px; + display: block; + position: absolute; + top: 5px; + right: 5px; + font-size: 28px; + z-index: 2; + } +} .modal-content { -webkit-box-shadow: none; box-shadow: none; diff --git a/modules/backend/assets/js/october.dragscroll.js b/modules/backend/assets/js/october.dragscroll.js index 38af4b4a2..c7436e705 100644 --- a/modules/backend/assets/js/october.dragscroll.js +++ b/modules/backend/assets/js/october.dragscroll.js @@ -84,6 +84,7 @@ return false }) + $(document).on('ready', $.proxy(this.fixScrollClasses, this)) $(window).on('resize', $.proxy(this.fixScrollClasses, this)) /* @@ -354,4 +355,4 @@ $.fn.dragScroll = old return this } -}(window.jQuery); \ No newline at end of file +}(window.jQuery); diff --git a/modules/backend/assets/js/october.inspector.js b/modules/backend/assets/js/october.inspector.js index 18b3fabff..84d186dac 100644 --- a/modules/backend/assets/js/october.inspector.js +++ b/modules/backend/assets/js/october.inspector.js @@ -85,7 +85,7 @@ 'an hidden input element with the data-inspector-values property.') } - Inspector.prototype.getPopoverTemplate = function() { + Inspector.prototype.getPopoverTemplate = function() { return ' \
\

{{title}}

\ @@ -182,14 +182,14 @@ this.$el.data('oc.inspectorVisible', true) var displayPopover = function() { - var offset = self.$el.data('inspector-offset') + var offset = self.$el.data('inspector-offset') if (offset === undefined) offset = 15 - - var offsetX = self.$el.data('inspector-offset-x'), - offsetY = self.$el.data('inspector-offset-y') - var placement = self.$el.data('inspector-placement') + var offsetX = self.$el.data('inspector-offset-x'), + offsetY = self.$el.data('inspector-offset-y') + + var placement = self.$el.data('inspector-placement') if (placement === undefined) placement = 'bottom' @@ -248,8 +248,9 @@ displayPopover() } - // Creates group nodes in the property set - // + /* + * Creates group nodes in the property set + */ Inspector.prototype.preprocessConfig = function() { var fields = [], result = { @@ -707,15 +708,15 @@ InspectorEditorDropdown.prototype.showLoadingIndicator = function() { if (!Modernizr.touch) - this.indicatorContainer.loadIndicator({'opaque': true}) + this.indicatorContainer.loadIndicator({'opaque': true}) } InspectorEditorDropdown.prototype.hideLoadingIndicator = function() { if (!Modernizr.touch) - this.indicatorContainer.loadIndicator('hide') + this.indicatorContainer.loadIndicator('hide') } - InspectorEditorDropdown.prototype.loadOptions= function() { + InspectorEditorDropdown.prototype.loadOptions = function() { var $form = $(this.selector).closest('form'), data = this.inspector.propertyValues, $select = $(this.selector), @@ -764,7 +765,7 @@ // INSPECTOR DATA-API // ================== - + $(document).on('click', '[data-inspectable]', function(){ var $this = $(this), inspector = $this.data('oc.inspector') diff --git a/modules/backend/assets/js/october.popover.js b/modules/backend/assets/js/october.popover.js index d9e39bd5c..e724cb63a 100644 --- a/modules/backend/assets/js/october.popover.js +++ b/modules/backend/assets/js/october.popover.js @@ -31,9 +31,10 @@ * Events: * - showing.oc.popover - triggered before the popover is displayed. Allows to override the * popover options (for example the content) or cancel the action with e.preventDefault() + * - show.oc.popover - triggered after the popover is displayed. * - hiding.oc.popover - triggered before the popover is closed. Allows to cancel the action with * e.preventDefault() - * - hide.oc.popover - triggered after the popover is hidden. + * - hide.oc.popover - triggered after the popover is hidden. * * JavaScript API: * $('#element').ocPopover({ @@ -55,14 +56,14 @@ Popover.prototype.hide = function() { var e = $.Event('hiding.oc.popover', {relatedTarget: this.$el}) this.$el.trigger(e, this) - if (e.isDefaultPrevented()) + if (e.isDefaultPrevented()) return if (this.$container) this.$container.remove() if (this.$overlay) this.$overlay.remove() - this.$overlay = false; - this.$container = false; + this.$overlay = false + this.$container = false this.$el.removeClass('popover-highlight') this.$el.data('oc.popover', null) @@ -70,46 +71,46 @@ $(document).unbind('mousedown', this.docClickHandler); this.$el.trigger('hide.oc.popover') - $(document).off('.oc.popover'); + $(document).off('.oc.popover') } Popover.prototype.show = function(options) { - var self = this; + var self = this /* * Trigger the show event */ var e = $.Event('showing.oc.popover', {relatedTarget: this.$el}) this.$el.trigger(e, this) - if (e.isDefaultPrevented()) + if (e.isDefaultPrevented()) return /* * Create the popover container and overlay */ - - this.$container = $('
') + this.$container = $('
') .addClass('control-popover') .css('visibility', 'hidden') if (this.options.containerClass) this.$container.addClass(this.options.containerClass) - var $content = $('
').html(this.getContent()) + var $content = $('
').html(this.getContent()) this.$container.append($content) if (this.options.width) this.$container.width(this.options.width) if (this.options.modal) { - this.$overlay = $('
').addClass('popover-overlay') + this.$overlay = $('
').addClass('popover-overlay') $(document.body).append(this.$overlay) if (this.options.highlightModalTarget) { this.$el.addClass('popover-highlight') this.$el.blur() } - } else + } else { this.$overlay = false + } if (this.options.container) $(this.options.container).append(this.$container); @@ -119,8 +120,7 @@ /* * Determine the popover position */ - - var + var placement = this.calcPlacement(), position = this.calcPosition(placement); @@ -132,14 +132,13 @@ /* * Display the popover */ - this.$container.css('visibility', 'visible') $(document.body).addClass('popover-open') + this.$el.trigger('show.oc.popover') /* * Bind events */ - this.$container.on('mousedown', function(e){ e.stopPropagation(); }) @@ -173,12 +172,12 @@ } Popover.prototype.calcDimensions = function() { - var + var documentWidth = $(document).width(), documentHeight = $(document).height(), targetOffset = this.$el.offset(), targetWidth = this.$el.outerWidth(), - targetHeight = this.$el.outerHeight(); + targetHeight = this.$el.outerHeight() return { containerWidth: this.$container.outerWidth() + this.arrowSize, @@ -242,24 +241,24 @@ } Popover.prototype.calcPosition = function(placement) { - var + var dimensions = this.calcDimensions(), - result; + result switch (placement) { - case 'left' : + case 'left': var realOffset = this.options.offsetY === undefined ? this.options.offset : this.options.offsetY result = {x: (dimensions.targetOffset.left - dimensions.containerWidth), y: dimensions.targetOffset.top + realOffset} break; - case 'top' : + case 'top': var realOffset = this.options.offsetX === undefined ? this.options.offset : this.options.offsetX result = {x: dimensions.targetOffset.left + realOffset, y: (dimensions.targetOffset.top - dimensions.containerHeight)} break; - case 'bottom' : + case 'bottom': var realOffset = this.options.offsetX === undefined ? this.options.offset : this.options.offsetX result = {x: dimensions.targetOffset.left + realOffset, y: (dimensions.targetOffset.top + dimensions.targetHeight + this.arrowSize)} break; - case 'right' : + case 'right': var realOffset = this.options.offsetY === undefined ? this.options.offset : this.options.offsetY result = {x: (dimensions.targetOffset.left + dimensions.targetWidth + this.arrowSize), y: dimensions.targetOffset.top + realOffset} break; @@ -268,14 +267,14 @@ if (!this.options.container) return result - var + var $container = $(this.options.container), - containerOffset = $container.offset(); + containerOffset = $container.offset() - result.x -= containerOffset.left; - result.y -= containerOffset.top; + result.x -= containerOffset.left + result.y -= containerOffset.top - return result; + return result } Popover.prototype.onDocumentClick = function() { diff --git a/modules/backend/assets/less/controls/filters.less b/modules/backend/assets/less/controls/filters.less index b9efd119f..db5a5934e 100644 --- a/modules/backend/assets/less/controls/filters.less +++ b/modules/backend/assets/less/controls/filters.less @@ -10,16 +10,17 @@ border-top: 1px solid @color-filter-border; border-bottom: 1px solid @color-filter-border; a { + text-decoration: none; color: @color-filter-text; - text-decoration: none; } - >.filter-set { + > .filter-scope { display: inline-block; padding: 15px; .filter-label {} .filter-setting { display: inline-block; + .transition(color 0.6s); } &:after { @@ -34,6 +35,18 @@ color: #FFF; background-color: @color-filter-bg-active; .border-radius(4px); + .transition(~'color 1s, background-color 1s'); + } + } + + &.checkbox { + padding-left: 35px; + &, label { + margin-bottom: 0; + } + + &:after { + content: ''; } } @@ -52,22 +65,37 @@ min-height: 36px; input { min-height: 36px; - border: none; + border: none; border-bottom: 1px solid @color-filter-border; - background: transparent url(../images/bitmap-icons.png) no-repeat 100% -82px; - .border-radius(0); + .border-bottom-radius(0); + background-color: transparent; + } + + .form-control.icon.search { + background-position: right -81px; + } + + .close { + display: none; } } .filter-items, .filter-active-items { color: @color-filter-text; font-size: 13px; - ul, li { list-style-type: none; margin:0; padding:0; } + ul, li { + list-style-type: none; + margin:0; + padding:0; + } + li { + .transition(~'color 0.6s, background-color 0.3s'); + } a { text-decoration: none; color: @color-filter-text; display: block; - padding: 7px 15px; + padding: 7px 15px; &:before { margin-right: 8px; @@ -83,12 +111,57 @@ } .filter-items { + height: 100px; + overflow: auto; + background-color: @color-filter-items-bg; border-bottom: 1px solid @color-filter-border; a:before { .icon(@plus); } + + li.loading { + padding: 7px; + > span { + display: block; + height: 20px; + width: 20px; + background-image: url(../images/loading-indicator.svg); + background-size: 20px 20px; + background-position: 50% 50%; + .animation(spin 1s linear infinite); + } + } + + li.animate-enter { .animation(fadeInUp .5s); } } .filter-active-items { a:before { .icon(@times); } + li.animate-enter { .animation(fadeInDown .5s); } + } +} + +@media (max-width: @screen-xs) { + .control-filter-popover { + .filter-items { + height: 200px; + } + .filter-search { + input { + padding-left: 36px; + padding-right: 36px; + } + .form-control.icon.search { + background-position: 0 -81px; + } + .close { + width: 30px; + display: block; + position: absolute; + top: 5px; + right: 5px; + font-size: 28px; + z-index: 2; + } + } } } \ No newline at end of file diff --git a/modules/backend/assets/less/core/animations.less b/modules/backend/assets/less/core/animations.less new file mode 100644 index 000000000..c0f7fe0bb --- /dev/null +++ b/modules/backend/assets/less/core/animations.less @@ -0,0 +1,363 @@ +// +// Fade In +// + +@-webkit-keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.fadeIn { + -webkit-animation-name: fadeIn; + animation-name: fadeIn; +} + +// +// Fade In Down +// + +@-webkit-keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} + +.fadeInDown { + -webkit-animation-name: fadeInDown; + animation-name: fadeInDown; +} + +// +// Fade In Left +// + +@-webkit-keyframes fadeInLeft { + 0% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@keyframes fadeInLeft { + 0% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} + +.fadeInLeft { + -webkit-animation-name: fadeInLeft; + animation-name: fadeInLeft; +} + +// +// Fade In Right +// + +@-webkit-keyframes fadeInRight { + 0% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@keyframes fadeInRight { + 0% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} + +.fadeInRight { + -webkit-animation-name: fadeInRight; + animation-name: fadeInRight; +} + +// +// Fade In Up +// + +@-webkit-keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + -ms-transform: none; + transform: none; + } +} + +.fadeInUp { + -webkit-animation-name: fadeInUp; + animation-name: fadeInUp; +} + +@-webkit-keyframes fadeInUpBig { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 2000px, 0); + transform: translate3d(0, 2000px, 0); + } + + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +// +// Fade Out +// + +@-webkit-keyframes fadeOut { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.fadeOut { + -webkit-animation-name: fadeOut; + animation-name: fadeOut; +} + +// +// Fade Out Down +// + +@-webkit-keyframes fadeOutDown { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} + +@keyframes fadeOutDown { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} + +.fadeOutDown { + -webkit-animation-name: fadeOutDown; + animation-name: fadeOutDown; +} + +// +// Fade Out Left +// + +@-webkit-keyframes fadeOutLeft { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} + +@keyframes fadeOutLeft { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} + +.fadeOutLeft { + -webkit-animation-name: fadeOutLeft; + animation-name: fadeOutLeft; +} + +// +// Fade Out Right +// + +@-webkit-keyframes fadeOutRight { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} + +@keyframes fadeOutRight { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} + +.fadeOutRight { + -webkit-animation-name: fadeOutRight; + animation-name: fadeOutRight; +} + +// +// Fade Out Up +// + +@-webkit-keyframes fadeOutUp { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} + +@keyframes fadeOutUp { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} + +.fadeOutUp { + -webkit-animation-name: fadeOutUp; + animation-name: fadeOutUp; +} diff --git a/modules/backend/assets/less/core/flags.less b/modules/backend/assets/less/core/flags.less index be84734bc..19c44a983 100644 --- a/modules/backend/assets/less/core/flags.less +++ b/modules/backend/assets/less/core/flags.less @@ -1,13 +1,13 @@ [class^="flag-"], [class*=" flag-"] { - background:url(../images/flag-icons-small.png) no-repeat; + .img-retina('../images/flag-icons-small.png', '../images/flag-icons-large.png', 16px, 3952px); width: 16px; height: 16px; line-height: 16px; vertical-align: middle; display: inline-block; - margin: -1px 2px 0 2px; + margin: -3px 2px 0 2px; } .flag-AfricanUnion { background-position:0 -16px } diff --git a/modules/backend/assets/less/october.less b/modules/backend/assets/less/october.less index bbb7d0bfe..a9b16c33f 100644 --- a/modules/backend/assets/less/october.less +++ b/modules/backend/assets/less/october.less @@ -4,6 +4,7 @@ @import "core/icons.less"; @import "core/flags.less"; @import "core/fonts.less"; +@import "core/animations.less"; // Boot variables and mixins @import "core/boot.less"; diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index 444a0f2fe..5509127f5 100644 --- a/modules/backend/behaviors/ListController.php +++ b/modules/backend/behaviors/ListController.php @@ -36,6 +36,11 @@ class ListController extends ControllerBehavior */ protected $toolbarWidgets = []; + /** + * @var WidgetBase Reference to the filter widget objects. + */ + protected $filterWidgets = []; + /** * {@inheritDoc} */ @@ -174,6 +179,31 @@ class ListController extends ControllerBehavior $this->toolbarWidgets[$definition] = $toolbarWidget; } + /* + * Prepare the filter widget (optional) + */ + if (isset($listConfig->filter)) { + $widget->cssClasses[] = 'list-flush'; + + $filterConfig = $this->makeConfig($listConfig->filter); + $filterConfig->alias = $widget->alias . 'Filter'; + $filterWidget = $this->makeWidget('Backend\Widgets\Filter', $filterConfig); + $filterWidget->bindToController(); + + /* + * Filter the list when the scopes are changed + */ + $filterWidget->bindEvent('filter.update', function() use ($widget, $filterWidget){ + $widget->addFilter([$filterWidget, 'applyAllScopesToQuery']); + return $widget->onRefresh(); + }); + + // Apply predefined filter values + $widget->addFilter([$filterWidget, 'applyAllScopesToQuery']); + + $this->filterWidgets[$definition] = $filterWidget; + } + return $widget; } @@ -206,6 +236,9 @@ class ListController extends ControllerBehavior if (isset($this->toolbarWidgets[$definition])) $collection[] = $this->toolbarWidgets[$definition]->render(); + if (isset($this->filterWidgets[$definition])) + $collection[] = $this->filterWidgets[$definition]->render(); + $collection[] = $this->listWidgets[$definition]->render(); return implode(PHP_EOL, $collection); diff --git a/modules/backend/behaviors/relationcontroller/partials/_button_add.htm b/modules/backend/behaviors/relationcontroller/partials/_button_add.htm index 9247d6539..65f0640c3 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_button_add.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_button_add.htm @@ -3,5 +3,5 @@ data-handler="onRelationManageForm" href="javascript:;" class="btn btn-sm btn-primary oc-icon-plus"> - $relationLabel]) ?> + trans($relationLabel)])) ?> diff --git a/modules/backend/behaviors/relationcontroller/partials/_button_create.htm b/modules/backend/behaviors/relationcontroller/partials/_button_create.htm index f7a455559..77ada5bbe 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_button_create.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_button_create.htm @@ -3,5 +3,5 @@ data-handler="onRelationManageForm" href="javascript:;" class="btn btn-sm btn-primary oc-icon-plus"> - $relationLabel]) ?> + trans($relationLabel)])) ?> diff --git a/modules/backend/behaviors/relationcontroller/partials/_button_delete.htm b/modules/backend/behaviors/relationcontroller/partials/_button_delete.htm index 274a686b0..108ff4af2 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_button_delete.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_button_delete.htm @@ -5,10 +5,10 @@ })" disabled="disabled" data-request="onRelationManageDelete" - data-request-confirm="Are you sure?" + data-request-confirm="" data-trigger-type="enable" data-trigger="#relationGetId('view') ?> .control-list input[type=checkbox]" data-trigger-condition="checked" data-stripe-load-indicator> - + \ No newline at end of file diff --git a/modules/backend/behaviors/relationcontroller/partials/_button_remove.htm b/modules/backend/behaviors/relationcontroller/partials/_button_remove.htm index 6e680af62..2ddd2770f 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_button_remove.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_button_remove.htm @@ -9,5 +9,5 @@ data-trigger="#relationGetId('view') ?> .control-list input[type=checkbox]" data-trigger-condition="checked" data-stripe-load-indicator> - + \ No newline at end of file diff --git a/modules/backend/behaviors/relationcontroller/partials/_manage_form.htm b/modules/backend/behaviors/relationcontroller/partials/_manage_form.htm index 5fe598275..a29cc76fa 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_manage_form.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_manage_form.htm @@ -7,7 +7,7 @@ @@ -40,7 +40,7 @@ diff --git a/modules/backend/behaviors/relationcontroller/partials/_manage_list.htm b/modules/backend/behaviors/relationcontroller/partials/_manage_list.htm index ba14e5ac4..2faa1e019 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_manage_list.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_manage_list.htm @@ -2,7 +2,7 @@
render() ?> @@ -14,13 +14,13 @@ data-request="onRelationManageAdd" data-dismiss="popup" data-stripe-load-indicator> - Add selected +
diff --git a/modules/backend/behaviors/relationcontroller/partials/_manage_pivot.htm b/modules/backend/behaviors/relationcontroller/partials/_manage_pivot.htm index 1222b49ec..db8b55db8 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_manage_pivot.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_manage_pivot.htm @@ -2,10 +2,10 @@ render() ?> @@ -15,7 +15,7 @@ type="button" class="btn btn-default" data-dismiss="popup"> - Cancel +
diff --git a/modules/backend/behaviors/relationcontroller/partials/_pivot_form.htm b/modules/backend/behaviors/relationcontroller/partials/_pivot_form.htm index 0dcfa363e..972b25041 100644 --- a/modules/backend/behaviors/relationcontroller/partials/_pivot_form.htm +++ b/modules/backend/behaviors/relationcontroller/partials/_pivot_form.htm @@ -7,7 +7,7 @@ @@ -38,7 +38,7 @@ diff --git a/modules/backend/classes/BackendController.php b/modules/backend/classes/BackendController.php index 41537477c..03942a713 100644 --- a/modules/backend/classes/BackendController.php +++ b/modules/backend/classes/BackendController.php @@ -43,7 +43,7 @@ class BackendController extends ControllerBase */ $module = isset($params[0]) ? $params[0] : 'backend'; $controller = isset($params[1]) ? $params[1] : 'index'; - self::$action = $action = isset($params[2]) ? camel_case($params[2]) : 'index'; + self::$action = $action = isset($params[2]) ? $this->parseAction($params[2]) : 'index'; self::$params = $controllerParams = array_slice($params, 3); $controllerClass = '\\'.$module.'\Controllers\\'.$controller; if ($controllerObj = $this->findController($controllerClass, $action, '/modules')) @@ -55,7 +55,7 @@ class BackendController extends ControllerBase if (count($params) >= 2) { list($author, $plugin) = $params; $controller = isset($params[2]) ? $params[2] : 'index'; - self::$action = $action = isset($params[3]) ? camel_case($params[3]) : 'index'; + self::$action = $action = isset($params[3]) ? $this->parseAction($params[3]) : 'index'; self::$params = $controllerParams = array_slice($params, 4); $controllerClass = '\\'.$author.'\\'.$plugin.'\Controllers\\'.$controller; if ($controllerObj = $this->findController($controllerClass, $action, Config::get('cms.pluginsDir', '/plugins'))) @@ -97,4 +97,17 @@ class BackendController extends ControllerBase return false; } + + /** + * Process the action name, since dashes are not supported in PHP methods. + * @param string $actionName + * @return string + */ + protected function parseAction($actionName) + { + if (strpos($actionName, '-') !== false) + return camel_case($actionName); + + return $actionName; + } } \ No newline at end of file diff --git a/modules/backend/classes/Controller.php b/modules/backend/classes/Controller.php index 03b59e408..bc64d5868 100644 --- a/modules/backend/classes/Controller.php +++ b/modules/backend/classes/Controller.php @@ -325,7 +325,7 @@ class Controller extends Extendable * Execute the handler */ if (!$result = $this->runAjaxHandler($handler)) - throw new SystemException(Lang::get('cms::lang.ajax_handler.not_found', ['name'=>$handler])); + throw new ApplicationException(Lang::get('cms::lang.ajax_handler.not_found', ['name'=>$handler])); /* * If the handler returned an array, we should add it to output for rendering. diff --git a/modules/backend/classes/FilterScope.php b/modules/backend/classes/FilterScope.php new file mode 100644 index 000000000..f836e2ff1 --- /dev/null +++ b/modules/backend/classes/FilterScope.php @@ -0,0 +1,147 @@ +scopeName = $scopeName; + $this->label = $label; + } + + /** + * Specifies a scope control rendering mode. Supported modes are: + * - group - filter by a group of IDs. Default. + * - checkbox - filter by a simple toggle switch. + * @param string $type Specifies a render mode as described above + * @param array $config A list of render mode specific config. + */ + public function displayAs($type, $config = []) + { + $this->type = strtolower($type) ?: $this->type; + $this->config = $this->evalConfig($config); + return $this; + } + + /** + * Process options and apply them to this object. + * @param array $config + * @return array + */ + protected function evalConfig($config) + { + if (isset($config['options'])) $this->options($config['options']); + if (isset($config['context'])) $this->context = $config['context']; + if (isset($config['default'])) $this->defaults = $config['default']; + if (isset($config['conditions'])) $this->conditions = $config['conditions']; + if (isset($config['scope'])) $this->scope = $config['scope']; + if (isset($config['cssClass'])) $this->cssClass = $config['cssClass']; + if (isset($config['nameColumn'])) $this->nameColumn = $config['nameColumn']; + if (isset($config['descriptionColumn'])) $this->descriptionColumn = $config['descriptionColumn']; + + if (array_key_exists('disabled', $config)) $this->disabled = $config['disabled']; + return $config; + } + + /** + * Returns a value suitable for the scope id property. + */ + public function getId($suffix = null) + { + $id = 'scope'; + $id .= '-'.$this->scopeName; + + if ($suffix) + $id .= '-'.$suffix; + + if ($this->idPrefix) + $id = $this->idPrefix . '-' . $id; + + $id = rtrim(str_replace(['[', ']'], '-', $id), '-'); + return $id; + } + +} \ No newline at end of file diff --git a/modules/backend/controllers/Users.php b/modules/backend/controllers/Users.php index 36a067a53..ac6b25f41 100644 --- a/modules/backend/controllers/Users.php +++ b/modules/backend/controllers/Users.php @@ -98,9 +98,9 @@ class Users extends Controller 'comment' => $permission->comment, 'type' => 'balloon-selector', 'options' => [ - 1 => 'Allow', - 0 => 'Inherit', - -1 => 'Deny', + 1 => 'backend::lang.user.allow', + 0 => 'backend::lang.user.inherit', + -1 => 'backend::lang.user.deny', ], 'attributes' => [ 'data-trigger' => "input[name='User[permissions][superuser]']", diff --git a/modules/backend/controllers/auth/reset.htm b/modules/backend/controllers/auth/reset.htm index cd49622ab..369a04288 100644 --- a/modules/backend/controllers/auth/reset.htm +++ b/modules/backend/controllers/auth/reset.htm @@ -1,4 +1,4 @@ -

+

@@ -26,7 +26,7 @@

- +

diff --git a/modules/backend/controllers/auth/restore.htm b/modules/backend/controllers/auth/restore.htm index eedbd34ea..6dfe47573 100644 --- a/modules/backend/controllers/auth/restore.htm +++ b/modules/backend/controllers/auth/restore.htm @@ -1,4 +1,4 @@ -

+

@@ -22,7 +22,7 @@

- +

diff --git a/modules/backend/controllers/auth/signin.htm b/modules/backend/controllers/auth/signin.htm index b9a38da4f..82ce38808 100644 --- a/modules/backend/controllers/auth/signin.htm +++ b/modules/backend/controllers/auth/signin.htm @@ -1,4 +1,4 @@ -

+

@@ -34,7 +34,7 @@

- +

diff --git a/modules/backend/formwidgets/codeeditor/partials/_codeeditor.htm b/modules/backend/formwidgets/codeeditor/partials/_codeeditor.htm index 82001334a..fbfe2bf5d 100644 --- a/modules/backend/formwidgets/codeeditor/partials/_codeeditor.htm +++ b/modules/backend/formwidgets/codeeditor/partials/_codeeditor.htm @@ -22,13 +22,13 @@
  • - Enter fullscreen mode +
  • - Exit fullscreen mode +
  • diff --git a/modules/backend/formwidgets/fileupload/partials/_config_form.htm b/modules/backend/formwidgets/fileupload/partials/_config_form.htm index bd918c372..ff4bce620 100644 --- a/modules/backend/formwidgets/fileupload/partials/_config_form.htm +++ b/modules/backend/formwidgets/fileupload/partials/_config_form.htm @@ -3,15 +3,13 @@ \ No newline at end of file diff --git a/modules/backend/lang/en/lang.php b/modules/backend/lang/en/lang.php index 5bbaff5e5..e6483cc11 100644 --- a/modules/backend/lang/en/lang.php +++ b/modules/backend/lang/en/lang.php @@ -48,6 +48,21 @@ return [ 'widget_label' => 'Widget', 'widget_width' => 'Width', 'add_widget' => 'Add widget', + 'widget_inspector_title' => 'Widget configuration', + 'widget_inspector_description' => 'Configure the report widget', + 'widget_columns_label' => 'Width :columns', + 'widget_columns_description' => 'The widget width, a number between 1 and 10.', + 'widget_columns_error' => 'Please enter the widget width as a number between 1 and 10.', + 'columns' => '{1} column|[2,Inf] columns', + 'widget_new_row_label' => 'Force new row', + 'widget_new_row_description' => 'Put the widget in a new row.', + 'widget_title_label' => 'Widget title', + 'widget_title_error' => 'The Widget Title is required.', + 'status' => [ + 'widget_title_default' => 'System status', + 'online' => 'online', + 'update_available' => '{0} updates available!|{1} update available!|[2,Inf] updates available!', + ] ], 'user' => [ 'name' => 'Administrator', @@ -71,6 +86,9 @@ return [ 'send_invite_comment' => 'Use this checkbox to send an invitation to the user by email', 'delete_confirm' => 'Do you really want to delete this administrator?', 'return' => 'Return to the administrator list', + 'allow' => 'Allow', + 'inherit' => 'Inherit', + 'deny' => 'Deny', 'group' => [ 'name' => 'Group', 'name_field' => 'Name', @@ -94,6 +112,22 @@ return [ 'missing_definition' => "List behavior does not contain a column for ':field'.", 'behavior_not_ready' => 'List behavior has not been initialized, check that you have called makeLists() in your controller.', 'invalid_column_datetime' => "Column value ':column' is not a DateTime object, are you missing a \$dates reference in the Model?", + 'pagination' => 'Displayed records: :from-:to of :total', + 'prev_page' => 'Previous page', + 'next_page' => 'Next page', + 'loading' => 'Loading...', + 'setup_title' => 'List Setup', + 'setup_help' => 'Use checkboxes to select columns you want to see in the list. You can change position of columns by dragging them up or down.', + 'records_per_page' => 'Records per page', + 'records_per_page_help' => 'Select the number of records per page to display. Please note that high number of records on a single page can reduce performance.', + 'apply_changes' => 'Apply changes', + 'cancel' => 'Cancel' + ], + 'fileupload' => [ + 'attachment' => 'Attachment', + 'help' => 'Add a title and description for this attachment.', + 'title_label' => 'Title', + 'description_label' => 'Description' ], 'form' => [ 'create_title' => "New :name", @@ -129,13 +163,20 @@ return [ 'select' => 'Select', 'select_all' => 'all', 'select_none' => 'none', + 'insert_row' => 'Insert Row', + 'delete_row' => 'Delete Row' ], 'relation' => [ 'missing_definition' => "Relation behavior does not contain a definition for ':field'.", 'missing_model' => "Relation behavior used in :class does not have a model defined.", 'invalid_action_single' => "This action cannot be performed on a singular relationship.", 'invalid_action_multi' => "This action cannot be performed on a multiple relationship.", + 'help' => "Click on an item to add", + 'related_data' => "Related :name data", 'add' => "Add", + 'add_selected' => "Add selected", + 'add_a_new' => "Add a new :name", + 'cancel' => "Cancel", 'add_name' => "Add :name", 'create' => "Create", 'create_name' => "Create :name", @@ -145,6 +186,7 @@ return [ 'remove_name' => "Remove :name", 'delete' => "Delete", 'delete_name' => "Delete :name", + 'delete_confirm' => "Are you sure?", ], 'model' => [ 'name' => "Model", diff --git a/modules/backend/lang/fr/lang.php b/modules/backend/lang/fr/lang.php index de3c3a6d4..a2f57a87d 100644 --- a/modules/backend/lang/fr/lang.php +++ b/modules/backend/lang/fr/lang.php @@ -45,6 +45,9 @@ return [ ], 'dashboard' => [ 'menu_label' => 'Tableau de bord', + 'widget_label' => 'Widget', + 'widget_width' => 'Taille', + 'add_widget' => 'Ajouter un widget', ], 'user' => [ 'name' => 'Administrateur', @@ -114,6 +117,7 @@ return [ 'undefined_tab' => 'Misc', 'field_off' => 'Off', 'field_on' => 'On', + 'add' => 'Ajouter', 'apply' => 'Appliquer', 'cancel' => 'Annuler', 'close' => 'Fermer', diff --git a/modules/backend/lang/it/lang.php b/modules/backend/lang/it/lang.php new file mode 100644 index 000000000..b93286705 --- /dev/null +++ b/modules/backend/lang/it/lang.php @@ -0,0 +1,227 @@ + [ + 'invalid_type' => 'Il tipo di campo :type non è valido.', + 'options_method_not_exists' => 'La classe :model deve definire un metodo :method() che ritorni le opzioni per il campo ":field".', + ], + 'widget' => [ + 'not_registered' => "Nessun widget ':name' è stato registrato", + 'not_bound' => "Nessun widget ':name' è stato legato al controller", + ], + 'page' => [ + 'untitled' => "Senza titolo", + 'access_denied' => [ + 'label' => "Accesso negato", + 'help' => "Non hai le autorizzazioni necessarie per accedere a questa pagina.", + 'cms_link' => "Ritorna al pannello di controllo", + ], + ], + 'partial' => [ + 'not_found' => "La vista parziale ':name' non è stata trovata.", + ], + 'account' => [ + 'sign_out' => 'Esci', + 'login' => 'Accedi', + 'reset' => 'Reimposta', + 'restore' => 'Ripristina', + 'login_placeholder' => 'login', + 'password_placeholder' => 'password', + 'forgot_password' => "Dimenticato la password?", + 'enter_email' => "Inserisci in tuo indirizzo e-mail", + 'enter_login' => "Inserisci il login.", + 'email_placeholder' => "email", + 'enter_new_password' => "Inserisci una nuova password", + 'password_reset' => "Reimposta password", + 'restore_success' => "Le istruzioni per reimpostare la password sono state inviate al tuo indirizzo e-mail.", + 'restore_error' => "Nessun utente con login ':login' è stato trovato.", + 'reset_success' => "La tua password è stata reimpostata con successo. Ora puoi effettuare l'accesso.", + 'reset_error' => "I dati forniti per la reimpostazione della password non sono validi. Riprova!", + 'reset_fail' => "Impossibile ripristinare la password!", + 'apply' => 'Applica', + 'cancel' => 'Annulla', + 'delete' => 'Elimina', + 'ok' => 'OK', + ], + 'dashboard' => [ + 'menu_label' => 'Dashboard', + 'widget_label' => 'Widget', + 'widget_width' => 'Larghezza', + 'add_widget' => 'Aggiungi widget', + 'widget_inspector_title' => 'Configurazione widget', + 'widget_inspector_description' => 'Configura il widget', + 'widget_columns_label' => 'Larghezza :columns', + 'widget_columns_description' => 'La larghezza del widget, un numero compreso tra 1 e 10.', + 'widget_columns_error' => 'La larghezza del widget deve essere un numero compreso tra 1 e 10.', + 'columns' => '{1} colonna|[2,Inf] colonne', + 'widget_new_row_label' => 'Forza nuova riga', + 'widget_new_row_description' => 'Inserisci il widget su una nuova riga.', + 'widget_title_label' => 'Titolo del widget', + 'widget_title_error' => 'Il titolo del widget è un campo obbligatorio.', + 'status' => [ + 'widget_title_default' => 'Stato del sistema', + 'online' => 'online', + 'update_available' => '{0} aggiornamenti disponibili!|{1} aggiornamento disponibile!|[2,Inf] aggiornamenti disponibili!', + ] + ], + 'user' => [ + 'name' => 'Amministratori', + 'menu_label' => 'Amministratori', + 'menu_description' => 'Gestisci gli utenti amministratori, i gruppi e le autorizzazioni.', + 'list_title' => 'Gestisci amministratori', + 'new' => 'Nuovo amministratorre', + 'login' => "Login", + 'first_name' => "Nome", + 'last_name' => "Cognome", + 'full_name' => "Nome completo", + 'email' => "Indirizzo e-mail", + 'groups' => "Gruppi", + 'groups_comment' => "Seleziona i gruppi a cui appartiene l'utente.", + 'avatar' => "Avatar", + 'password' => "Password", + 'password_confirmation' => "Conferma password", + 'superuser' => "Super User", + 'superuser_comment' => "Seleziona per consentire all'utente di accedere a tutte le aree.", + 'send_invite' => 'Invia invito tramite e-mail', + 'send_invite_comment' => 'Seleziona per inviare un invito all\'utente tramite e-mail', + 'delete_confirm' => 'Vuoi davvero eliminare questo amministratore?', + 'return' => 'Ritorna alla lista degli amministratori', + 'group' => [ + 'name' => 'Gruppo', + 'name_field' => 'Nome', + 'menu_label' => 'Gruppi', + 'list_title' => 'Gestisci gruppi', + 'new' => 'Nuovo gruppo amministratore', + 'delete_confirm' => 'Vuoi davvero eliminare questo gruppo amministratore?', + 'return' => 'Ritorna alla lista dei gruppi', + ], + 'preferences' => [ + 'not_authenticated' => 'Non c\'è nessun utente autenticato per cui caricare o salvare le preferenze.' + ] + ], + 'list' => [ + 'default_title' => 'Elenco', + 'search_prompt' => 'Cerca...', + 'no_records' => 'Nessun risultato trovato.', + 'missing_model' => 'L\'elenco utilizzato nella classe :class non ha un modello definito.', + 'missing_column' => 'Non ci sono colonne definite per :columns.', + 'missing_columns' => 'L\'elenco utilizzato nella classe :class non ha un elenco di colonne definito.', + 'missing_definition' => "L'elenco non contiene una colonna per il campo ':field'.", + 'behavior_not_ready' => 'L\'elenco non è stato inizializzato, controlla di aver chiamato il metodo makeLists() nel controller.', + 'invalid_column_datetime' => "Il valore della colonna ':column' non è un oggetto di tipo DateTime, hai dimenticato un riferimento a \$dates nel modello?", + 'pagination' => 'Record visualizzati: :from-:to di :total', + 'setup_title' => 'Configura elenco', + 'setup_help' => 'Utilizza le checkbox per selezionare le colonne che vuoi visualizzare nell\'elenco. Puoi cambiare la posizione delle colonne trascinandole verso l\'alto o il basso.', + 'records_per_page' => 'Record per pagina', + 'records_per_page_help' => 'Seleziona il numero di record da visualizzare su ogni pagina. Ricorda che un numero elevato di record in una singola pagina può ridurre le prestazioni.', + 'apply_changes' => 'Applica modifiche', + 'cancel' => 'Annulla' + ], + 'form' => [ + 'create_title' => "Nuovo :name", + 'update_title' => "Modifica :name", + 'preview_title' => "Anteprima :name", + 'create_success' => ':name creato con successo', + 'update_success' => ':name modificato con successo', + 'delete_success' => ':name eliminato con successo', + 'missing_id' => "L'ID del record non è stato specificato.", + 'missing_model' => 'Il form utilizzato nella classe :class non ha un modello definito.', + 'missing_definition' => "Il form non contiene il campo ':field'.", + 'not_found' => 'Nessun record per l\'ID :id è stato trovato.', + 'create' => 'Crea', + 'create_and_close' => 'Crea e chiudi', + 'creating' => 'Creazione in corso...', + 'save' => 'Salva', + 'save_and_close' => 'Salva e chiudi', + 'saving' => 'Salvataggio in corso...', + 'delete' => 'Elimina', + 'deleting' => 'Eliminazione in corso...', + 'undefined_tab' => 'Varie', + 'field_off' => 'Off', + 'field_on' => 'On', + 'add' => 'Aggiungi', + 'apply' => 'Applica', + 'cancel' => 'Annulla', + 'close' => 'Chiudi', + 'ok' => 'OK', + 'or' => 'o', + 'confirm_tab_close' => 'Vuoi davvero chiudere il tab? Le modifiche non salvate andranno perse.', + 'behavior_not_ready' => 'Il form non è stato inizializzato, verifica di aver chiamato il metodo initForm() nel controller.', + 'preview_no_files_message' => 'I file non sono stati caricati', + 'select' => 'Seleziona', + 'select_all' => 'tutti', + 'select_none' => 'nessuno', + ], + 'relation' => [ + 'missing_definition' => "La relazione non contiene una definizione per il campo ':field'.", + 'missing_model' => "La relazione utilizzata nella classe :class non ha un modello definito.", + 'invalid_action_single' => "L'azione non può essere eseguita su una relazione singola.", + 'invalid_action_multi' => "L'azione non può essere eseguita su una relazione multipla.", + 'add' => "Aggiungi", + 'add_name' => "Aggiungi :name", + 'create' => "Crea", + 'create_name' => "Crea :name", + 'update' => "Aggiorna", + 'update_name' => "Aggiorna :name", + 'remove' => "Rimuovi", + 'remove_name' => "Rimuovi :name", + 'delete' => "Elimina", + 'delete_name' => "Elimina :name", + ], + 'model' => [ + 'name' => "Modello", + 'not_found' => "Nessun modello di ':class' con l'ID :id trovato.", + 'missing_id' => "Nessun ID specificato.", + 'missing_relation' => "Il modello ':class' non contiene una definizione per la relazione ':relation'.", + 'invalid_class' => "Il modello :model utilizzato nella classe :class non è valido, deve ereditare la classe Model.", + 'mass_assignment_failed' => "Assegnazione massiva fallita per l'attributo ':attribute' del modello.", + ], + 'warnings' => [ + 'tips' => 'Suggerimenti di configurazione del sistema', + 'tips_description' => 'Ci sono elementi a cui è necessario prestare attenzione al fine di configurare il sistema in maniera corretta.', + 'permissions' => 'La cartella :name o le sue sottocartelle non sono scrivibili da PHP. Imposta le corrette autorizzazioni per il server web su questa cartella.', + 'extension' => 'L\'estenzione di PHP :name non è installata. Installa questa libreria ed attiva l\'estensione.' + ], + 'editor' => [ + 'menu_label' => 'Preferenze editor di codice', + 'menu_description' => 'Personalizza le impostazioni dell\'editor, come la dimensione del carattere e lo schema di colori.', + 'font_size' => 'Dimensione carattere', + 'tab_size' => 'Dimensione Tab', + 'use_hard_tabs' => 'Indenta utilizzando i Tab', + 'code_folding' => 'Raggruppa il codice', + 'word_wrap' => 'A capo automatico', + 'highlight_active_line' => 'Evidenzia la linea attiva', + 'show_invisibles' => 'Mostra caratteri invisibili', + 'show_gutter' => 'Visualizza numeri di linea', + 'theme' => 'Schema di colori', + ], + 'tooltips' => [ + 'preview_website' => 'Anteprima del sito web' + ], + 'mysettings' => [ + 'menu_label' => 'Impostazioni personali', + 'menu_description' => 'Impostazioni legate al tuo account amministratore', + ], + 'myaccount' => [ + 'menu_label' => 'Il mio account', + 'menu_description' => 'Aggiorna i dettagli del tuo account, come il nome, l\'indirizzo e-mail e la password.', + 'menu_keywords' => 'sicurezza login' + ], + 'backend_preferences' => [ + 'menu_label' => 'Preferenze pannello di controllo', + 'menu_description' => 'Gestisci le preferenze della lingua e l\'aspetto del pannello di controllo.', + 'locale' => 'Lingua', + 'locale_comment' => 'Seleziona la lingua da utilizzare.', + ], + 'access_log' => [ + 'hint' => 'Questo registro visualizza un elenco dei tentativi di accesso di un amministratore avvenuti con successo. I record sono mantenuti per un totale di :days giorni.', + 'menu_label' => 'Registro accessi', + 'menu_description' => 'Visualizza una lista degli accessi da parte degli amministratori.', + 'created_at' => 'Data e ora', + 'login' => 'Login', + 'ip_address' => 'Indirizzo IP', + 'first_name' => 'Nome', + 'last_name' => 'Cognome', + 'email' => 'Indirizzo e-mail', + ], +]; diff --git a/modules/backend/lang/ru/lang.php b/modules/backend/lang/ru/lang.php index 832e24bfe..b5986ba3d 100644 --- a/modules/backend/lang/ru/lang.php +++ b/modules/backend/lang/ru/lang.php @@ -45,6 +45,9 @@ return [ ], 'dashboard' => [ 'menu_label' => 'Панель управления', + 'widget_label' => 'Виджет', + 'widget_width' => 'Ширина', + 'add_widget' => 'Добавить виджет', ], 'user' => [ 'name' => 'Администратора', @@ -114,6 +117,7 @@ return [ 'undefined_tab' => 'Разное', 'field_off' => 'Выкл', 'field_on' => 'Вкл', + 'add' => 'Добавить', 'apply' => 'Применить', 'cancel' => 'Отмена', 'close' => 'Закрыть', @@ -187,4 +191,15 @@ return [ 'locale' => 'Язык', 'locale_comment' => 'Выберите желаемый язык панели управления.', ], + 'access_log' => [ + 'hint' => 'В этом журнале отображается список успешных попыток авторизаций администраторов. Записи хранятся :days дней.', + 'menu_label' => 'Журнал доступа', + 'menu_description' => 'Просмотр списка успешных авторизаций администраторов.', + 'created_at' => 'Дата & Время', + 'login' => 'Логин', + 'ip_address' => 'IP адересс', + 'first_name' => 'Имя', + 'last_name' => 'Фамилия', + 'email' => 'Почта', + ], ]; diff --git a/modules/backend/layouts/_footer.htm b/modules/backend/layouts/_footer.htm index 9ecfb7f77..97a3a97e8 100644 --- a/modules/backend/layouts/_footer.htm +++ b/modules/backend/layouts/_footer.htm @@ -1,9 +1,9 @@
    - +
    -

    +

    \ No newline at end of file diff --git a/modules/backend/layouts/_head.htm b/modules/backend/layouts/_head.htm index fc61b28d6..178b8fc56 100644 --- a/modules/backend/layouts/_head.htm +++ b/modules/backend/layouts/_head.htm @@ -1,6 +1,7 @@ - - + + + <?= $this->pageTitle ?> | October CMS diff --git a/modules/backend/layouts/_mainmenu.htm b/modules/backend/layouts/_mainmenu.htm index 81e2ae1aa..61e18be3f 100644 --- a/modules/backend/layouts/_mainmenu.htm +++ b/modules/backend/layouts/_mainmenu.htm @@ -28,7 +28,12 @@ */ ?>
  • - +
  • @@ -41,7 +46,13 @@
  • - + + +
  • diff --git a/modules/backend/layouts/auth.htm b/modules/backend/layouts/auth.htm index f2ba4eff0..331cb34de 100644 --- a/modules/backend/layouts/auth.htm +++ b/modules/backend/layouts/auth.htm @@ -26,7 +26,7 @@
    -

    +

    diff --git a/modules/backend/models/BackendPreferences.php b/modules/backend/models/BackendPreferences.php index 010c8cb52..b365af49a 100644 --- a/modules/backend/models/BackendPreferences.php +++ b/modules/backend/models/BackendPreferences.php @@ -27,20 +27,26 @@ class BackendPreferences extends Model public function getLocaleOptions() { - return [ + $locales = [ 'en' => [Lang::get('system::lang.locale.en'), 'flag-gb'], 'ru' => [Lang::get('system::lang.locale.ru'), 'flag-ru'], 'nl' => [Lang::get('system::lang.locale.nl'), 'flag-nl'], 'ja' => [Lang::get('system::lang.locale.ja'), 'flag-jp'], - 'sv' => [Lang::get('system::lang.locale.sv'), 'flag-sv'], + 'se' => [Lang::get('system::lang.locale.se'), 'flag-se'], 'tr' => [Lang::get('system::lang.locale.tr'), 'flag-tr'], 'br' => [Lang::get('system::lang.locale.br'), 'flag-br'], 'de' => [Lang::get('system::lang.locale.de'), 'flag-de'], + 'fr' => [Lang::get('system::lang.locale.fr'), 'flag-fr'], + 'it' => [Lang::get('system::lang.locale.it'), 'flag-it'], ]; + + // Sort the locales alphabetically + asort($locales); + return $locales; } public function afterSave() { Session::put('locale', $this->locale); } -} \ No newline at end of file +} diff --git a/modules/backend/models/EditorPreferences.php b/modules/backend/models/EditorPreferences.php index 9d021ccd9..739fd6f26 100644 --- a/modules/backend/models/EditorPreferences.php +++ b/modules/backend/models/EditorPreferences.php @@ -65,6 +65,6 @@ class EditorPreferences extends Model // Sort the theme alphabetically, and push the default theme asort($themes); - return [static::DEFAULT_THEME => 'Twilight'] + $themes; + return [static::DEFAULT_THEME => ucwords(static::DEFAULT_THEME)] + $themes; } } \ No newline at end of file diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 6bc473ce3..d1b34aa47 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -1,6 +1,11 @@ activeContext = $this->getConfig('context'); + } + + /** + * {@inheritDoc} + */ + public function loadAssets() + { + $this->addJs('js/october.filter.js', 'core'); + } + /** * Renders the widget. */ @@ -30,5 +77,388 @@ class Filter extends WidgetBase */ public function prepareVars() { + $this->defineFilterScopes(); + $this->vars['cssClasses'] = implode(' ', $this->cssClasses); + $this->vars['scopes'] = $this->allScopes; } + + /** + * Renders the HTML element for a scope + */ + public function renderScopeElement($scope) + { + return $this->makePartial('scope_'.$scope->type, ['scope' => $scope]); + } + + // + // AJAX + // + + /** + * Update a filter scope value. + * @return array + */ + public function onFilterUpdate() + { + $this->defineFilterScopes(); + + if (!$scope = post('scopeName')) + return; + + $scope = $this->getScope($scope); + + switch ($scope->type) { + case 'group': + $active = $this->optionsFromAjax(post('options.active')); + $this->setScopeValue($scope, $active); + break; + + case 'checkbox': + $checked = post('value') == 'true' ? true : false; + $this->setScopeValue($scope, $checked); + break; + } + + /* + * Trigger class event, merge results as viewable array + */ + $params = func_get_args(); + $result = $this->fireEvent('filter.update', [$params]); + if ($result && is_array($result)) + return Util::arrayMerge($result); + } + + /** + * Returns available options for group scope type. + * @return array + */ + public function onFilterGetOptions() + { + $this->defineFilterScopes(); + + $searchQuery = post('search'); + if (!$scopeName = post('scopeName')) + return; + + $scope = $this->getScope($scopeName); + $activeKeys = $scope->value ? array_keys($scope->value) : []; + $available = $this->getAvailableOptions($scope, $searchQuery); + $active = $searchQuery ? [] : $this->filterActiveOptions($activeKeys, $available); + + return [ + 'scopeName' => $scopeName, + 'options' => [ + 'available' => $this->optionsToAjax($available), + 'active' => $this->optionsToAjax($active), + ] + ]; + } + + // + // Internals + // + + /** + * Returns the available options a scope can use, either from the + * model relation or from a supplied array. Optionally apply a search + * constraint to the options. + * @param string $scope + * @param string $searchQuery + * @return array + */ + protected function getAvailableOptions($scope, $searchQuery = null) + { + $available = []; + $nameColumn = $this->getScopeNameColumn($scope); + $options = $this->getOptionsFromModel($scope, $searchQuery); + foreach ($options as $option) { + $available[$option->getKey()] = $option->{$nameColumn}; + } + return $available; + } + + /** + * Removes any already selected options from the available options, returns + * a newly built array. + * @param array $activeKeys + * @param array $availableOptions + * @return array + */ + protected function filterActiveOptions(array $activeKeys, array &$availableOptions) + { + $active = []; + foreach ($availableOptions as $id => $option) { + if (!in_array($id, $activeKeys)) + continue; + + $active[$id] = $option; + unset($availableOptions[$id]); + } + + return $active; + } + + /** + * Looks at the model for defined scope items. + */ + protected function getOptionsFromModel($scope, $searchQuery = null) + { + $model = $this->scopeModels[$scope->scopeName]; + if (!$searchQuery) + return $model->all(); + + $searchFields = [$model->getKeyName(), $this->getScopeNameColumn($scope)]; + return $model->searchWhere($searchQuery, $searchFields)->get(); + } + + /** + * Creates a flat array of filter scopes from the configuration. + */ + protected function defineFilterScopes() + { + if ($this->scopesDefined) + return; + + /* + * Extensibility + */ + Event::fire('backend.filter.extendScopesBefore', [$this]); + $this->fireEvent('filter.extendScopesBefore'); + + /* + * All scopes + */ + if (!isset($this->config->scopes) || !is_array($this->config->scopes)) + $this->config->scopes = []; + + $this->addScopes($this->config->scopes); + + /* + * Extensibility + */ + Event::fire('backend.filter.extendScopes', [$this]); + $this->fireEvent('filter.extendScopes'); + + $this->scopesDefined = true; + } + + /** + * Programatically add scopes, used internally and for extensibility. + */ + public function addScopes(array $scopes) + { + foreach ($scopes as $name => $config) { + + $scopeObj = $this->makeFilterScope($name, $config); + + /* + * Check that the filter scope matches the active context + */ + if ($scopeObj->context !== null) { + $context = (is_array($scopeObj->context)) ? $scopeObj->context : [$scopeObj->context]; + if (!in_array($this->getContext(), $context)) + continue; + } + + /* + * Validate scope model + */ + if (isset($config['modelClass'])) { + $class = $config['modelClass']; + $model = new $class; + $this->scopeModels[$name] = $model; + } + + $this->allScopes[$name] = $scopeObj; + } + } + + /** + * Creates a filter scope object from name and configuration. + */ + protected function makeFilterScope($name, $config) + { + $label = (isset($config['label'])) ? $config['label'] : null; + $scopeType = isset($config['type']) ? $config['type'] : null; + + $scope = new FilterScope($name, $label); + $scope->displayAs($scopeType, $config); + + /* + * Set scope value + */ + $scope->value = $this->getScopeValue($scope); + + return $scope; + } + + // + // Filter query logic + // + + /** + * Applies all scopes to a DB query. + * @param Builder $query + * @return Builder + */ + public function applyAllScopesToQuery($query) + { + foreach ($this->allScopes as $scope) { + $this->applyScopeToQuery($scope, $query); + } + + return $query; + } + + /** + * Applies a filter scope constraints to a DB query. + * @param string $scope + * @param Builder $query + * @return Builder + */ + public function applyScopeToQuery($scope, $query) + { + if (is_string($scope)) + $scope = $this->getScope($scope); + + if (!$scope->value) + return; + + $value = is_array($scope->value) ? array_keys($scope->value) : $scope->value; + + /* + * Condition + */ + if ($scopeConditions = $scope->conditions) { + if (is_array($value)) { + $filtered = implode(',', array_build($value, function($key, $_value){ + return [$key, Db::getPdo()->quote($_value)]; + })); + } + else { + $filtered = Db::getPdo()->quote($value); + } + + $query->whereRaw(strtr($scopeConditions, [':filtered' => $filtered])); + } + + /* + * Scope + */ + if ($scopeMethod = $scope->scope) { + $query->$scopeMethod($value); + } + + return $query; + } + + // + // Access layer + // + + /** + * Returns a scope value for this widget instance. + */ + public function getScopeValue($scope, $default = null) + { + if (is_string($scope)) + $scope = $this->getScope($scope); + + $cacheKey = 'scope-'.$scope->scopeName; + return $this->getSession($cacheKey, $default); + } + + /** + * Sets an scope value for this widget instance. + */ + public function setScopeValue($scope, $value) + { + if (is_string($scope)) + $scope = $this->getScope($scope); + + $cacheKey = 'scope-'.$scope->scopeName; + $this->putSession($cacheKey, $value); + + $scope->value = $value; + } + + /** + * Get all the registered scopes for the instance. + * @return array + */ + public function getScopes() + { + return $this->allScopes; + } + + /** + * Get a specified scope object + * @param string $scope + * @return mixed + */ + public function getScope($scope) + { + if (!isset($this->allScopes[$scope])) + throw new ApplicationException('No definition for scope ' . $scope); + + return $this->allScopes[$scope]; + } + + /** + * Returns the display name column for a scope. + * @param string $scope + * @return string + */ + public function getScopeNameColumn($scope) + { + if (is_string($scope)) + $scope = $this->getScope($scope); + + return $scope->nameColumn; + } + + /** + * Returns the active context for displaying the filter. + */ + public function getContext() + { + return $this->activeContext; + } + + // + // Helpers + // + + /** + * Convert a key/pair array to a named array {id: 1, name: 'Foobar'} + * @param array $options + * @return array + */ + protected function optionsToAjax($options) + { + $processed = []; + foreach ($options as $id => $result) { + $processed[] = ['id' => $id, 'name' => $result]; + } + return $processed; + } + + /** + * Convert a named array to a key/pair array + * @param array $options + * @return array + */ + protected function optionsFromAjax($options) + { + $processed = []; + if (!is_array($options)) + return $processed; + + foreach ($options as $option) { + if (!$id = array_get($option, 'id')) continue; + $processed[$id] = array_get($option, 'name'); + } + return $processed; + } + } \ No newline at end of file diff --git a/modules/backend/widgets/Lists.php b/modules/backend/widgets/Lists.php index d68c199df..a0cce838b 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -78,7 +78,12 @@ class Lists extends WidgetBase /** * @var string Filter the records by a search term. */ - public $searchTerm; + protected $searchTerm; + + /** + * @var array Collection of functions to apply to each list query. + */ + protected $filterCallbacks = []; /** * @var bool Shows the sorting options for each column. @@ -339,8 +344,11 @@ class Lists extends WidgetBase } /* - * @todo Apply filters etc + * Apply filters */ + foreach ($this->filterCallbacks as $callback) { + $callback($query); + } /* * Extensibility @@ -694,6 +702,15 @@ class Lists extends WidgetBase return $value; } + // + // Filtering + // + + public function addFilter(callable $filter) + { + $this->filterCallbacks[] = $filter; + } + // // Searching // diff --git a/modules/backend/widgets/ReportContainer.php b/modules/backend/widgets/ReportContainer.php index 61bd8e94d..623649777 100644 --- a/modules/backend/widgets/ReportContainer.php +++ b/modules/backend/widgets/ReportContainer.php @@ -1,11 +1,12 @@ getId('container-list') => $this->makePartial('widget', [ - 'widget' => $widget, + 'widget' => $widget, 'widgetAlias' => $widgetInfo['alias'], - 'sortOrder' => $widgetInfo['sortOrder'] + 'sortOrder' => $widgetInfo['sortOrder'] ]) ]; } @@ -282,45 +283,45 @@ class ReportContainer extends WidgetBase $property = [ 'property' => 'ocWidgetWidth', - 'title' => 'Width (1-10)', - 'description' => 'The widget width, a number between 1 and 10.', + 'title' => Lang::get('backend::lang.dashboard.widget_columns_label', ['columns' => '(1-10)']), + 'description' => Lang::get('backend::lang.dashboard.widget_columns_description'), 'type' => 'dropdown', 'validationPattern' => '^[0-9]+$', - 'validationMessage' => 'Please enter the widget width as a number between 1 and 10.', + 'validationMessage' => Lang::get('backend::lang.dashboard.widget_columns_error'), 'options' => [ - 1 => '1 column', - 2 => '2 columns', - 3 => '3 columns', - 4 => '4 columns', - 5 => '5 columns', - 6 => '6 columns', - 7 => '7 columns', - 8 => '8 columns', - 9 => '9 columns', - 10 => '10 columns' + 1 => '1 ' . Lang::choice('backend::lang.dashboard.columns', 1), + 2 => '2 ' . Lang::choice('backend::lang.dashboard.columns', 2), + 3 => '3 ' . Lang::choice('backend::lang.dashboard.columns', 3), + 4 => '4 ' . Lang::choice('backend::lang.dashboard.columns', 4), + 5 => '5 ' . Lang::choice('backend::lang.dashboard.columns', 5), + 6 => '6 ' . Lang::choice('backend::lang.dashboard.columns', 6), + 7 => '7 ' . Lang::choice('backend::lang.dashboard.columns', 7), + 8 => '8 ' . Lang::choice('backend::lang.dashboard.columns', 8), + 9 => '9 ' . Lang::choice('backend::lang.dashboard.columns', 9), + 10 => '10 ' . Lang::choice('backend::lang.dashboard.columns', 10) ] ]; $result[] = $property; $property = [ 'property' => 'ocWidgetNewRow', - 'title' => 'Force new row', - 'description' => 'Put the widget in a new row.', + 'title' => Lang::get('backend::lang.dashboard.widget_new_row_label'), + 'description' => Lang::get('backend::lang.dashboard.widget_new_row_description'), 'type' => 'checkbox' ]; $result[] = $property; - foreach ($properties as $name=>$params) { + foreach ($properties as $name => $params) { $property = [ 'property' => $name, - 'title' => isset($params['title']) ? $params['title'] : $name, + 'title' => isset($params['title']) ? Lang::get($params['title']) : $name, 'type' => isset($params['type']) ? $params['type'] : 'string' ]; foreach ($params as $name => $value) { if (isset($property[$name])) continue; - $property[$name] = $value; + $property[$name] = Lang::get($value); } $result[] = $property; @@ -334,8 +335,9 @@ class ReportContainer extends WidgetBase $result = []; $properties = $widget->defineProperties(); - foreach ($properties as $name=>$params) - $result[$name] = $widget->property($name); + foreach ($properties as $name => $params) { + $result[$name] = Lang::get($widget->property($name)); + } $result['ocWidgetWidth'] = $widget->property('ocWidgetWidth'); $result['ocWidgetNewRow'] = $widget->property('ocWidgetNewRow'); diff --git a/modules/backend/widgets/Search.php b/modules/backend/widgets/Search.php index fa75b8b6d..f7734d3be 100644 --- a/modules/backend/widgets/Search.php +++ b/modules/backend/widgets/Search.php @@ -109,7 +109,8 @@ class Search extends WidgetBase */ $params = func_get_args(); $result = $this->fireEvent('search.submit', [$params]); - return Util::arrayMerge($result); + if ($result && is_array($result)) + return Util::arrayMerge($result); } /** diff --git a/modules/backend/widgets/filter/assets/js/october.filter.js b/modules/backend/widgets/filter/assets/js/october.filter.js new file mode 100644 index 000000000..6ddd7b542 --- /dev/null +++ b/modules/backend/widgets/filter/assets/js/october.filter.js @@ -0,0 +1,367 @@ +/* + * Filter Widget + * + * Data attributes: + * - data-behavior="filter" - enables the filter plugin + * + * Dependences: + * - October Popover (october.popover.js) + */ ++function ($) { "use strict"; + + var FilterWidget = function (element, options) { + + var $el = this.$el = $(element); + + this.options = options || {} + this.scopeValues = {} + this.$activeScope = null + this.activeScopeName = null + this.isActiveScopeDirty = false + + this.init() + } + + FilterWidget.DEFAULTS = { + optionsHandler: null, + updateHandler: null + } + + /* + * Get popover template + */ + FilterWidget.prototype.getPopoverTemplate = function() { + return ' \ +
    \ + \ +
    \ + \ +
    \ +
      \ + {{#available}} \ +
    • {{name}}
    • \ + {{/available}} \ + {{#loading}} \ +
    • \ + {{/loading}} \ +
    \ +
    \ +
    \ +
      \ + {{#active}} \ +
    • {{name}}
    • \ + {{/active}} \ +
    \ +
    \ +
    \ +
    \ + ' + } + + FilterWidget.prototype.init = function() { + var self = this + + this.$el.on('change', '.filter-scope input[type="checkbox"]', function(){ + var isChecked = $(this).is(':checked'), + $scope = $(this).closest('.filter-scope'), + scopeName = $scope.data('scope-name') + + self.scopeValues[scopeName] = isChecked + self.checkboxToggle(scopeName, isChecked) + }) + + this.$el.on('click', 'a.filter-scope', function(){ + var $scope = $(this), + scopeName = $scope.data('scope-name') + + // Second click closes the filter scope + if ($scope.hasClass('filter-scope-open')) return + + self.$activeScope = $scope + self.activeScopeName = scopeName + self.isActiveScopeDirty = false + self.displayPopover($scope) + $scope.addClass('filter-scope-open') + }) + + this.$el.on('show.oc.popover', 'a.filter-scope', function(){ + self.focusSearch() + }) + + this.$el.on('hide.oc.popover', 'a.filter-scope', function(){ + var $scope = $(this) + self.pushOptions(self.activeScopeName) + self.activeScopeName = null + self.$activeScope = null + + // Second click closes the filter scope + setTimeout(function() { $scope.removeClass('filter-scope-open') }, 200) + }) + + $(document).on('click', '#controlFilterPopover .filter-items > ul > li', function(){ + self.selectItem($(this)) + }) + + $(document).on('click', '#controlFilterPopover .filter-active-items > ul > li', function(){ + self.selectItem($(this), true) + }) + + $(document).on('ajaxDone', '#controlFilterPopover input.filter-search-input', function(event, context, data){ + self.filterAvailable(data.scopeName, data.options.available) + }) + } + + FilterWidget.prototype.focusSearch = function() { + if (Modernizr.touch) + return + + var $input = $('#controlFilterPopover input.filter-search-input'), + length = $input.val().length + + $input.focus() + $input.get(0).setSelectionRange(length, length) + } + + FilterWidget.prototype.updateScopeSetting = function($scope, amount) { + var $setting = $scope.find('.filter-setting') + + if (amount) { + $setting.text(amount) + $scope.addClass('active') + } + else { + $setting.text('all') + $scope.removeClass('active') + } + } + + FilterWidget.prototype.selectItem = function($item, isDeselect) { + var $otherContainer = isDeselect + ? $item.closest('.control-filter-popover').find('.filter-items:first > ul') + : $item.closest('.control-filter-popover').find('.filter-active-items:first > ul') + + $item + .addClass('animate-enter') + .prependTo($otherContainer) + .one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function(){ + $(this).removeClass('animate-enter') + }) + + if (!this.scopeValues[this.activeScopeName]) + return + + var + itemId = $item.data('item-id'), + items = this.scopeValues[this.activeScopeName], + fromItems = isDeselect ? items.active : items.available, + toItems = isDeselect ? items.available : items.active, + testFunc = function(item){ return item.id == itemId }, + item = $.grep(fromItems, testFunc).pop(), + filtered = $.grep(fromItems, testFunc, true) + + if (isDeselect) + this.scopeValues[this.activeScopeName].active = filtered + else + this.scopeValues[this.activeScopeName].available = filtered + + if (item) + toItems.push(item) + + this.updateScopeSetting(this.$activeScope, items.active.length) + this.isActiveScopeDirty = true + this.focusSearch() + } + + FilterWidget.prototype.displayPopover = function($scope) { + var self = this, + scopeName = $scope.data('scope-name'), + data = this.scopeValues[scopeName] + + if (!data) { + self.loadOptions(scopeName) + data = { loading: true } + } + + data.scopeName = scopeName + data.optionsHandler = self.options.optionsHandler + + // Destroy any popovers already bound + $scope.data('oc.popover', null) + + $scope.ocPopover({ + content: Mustache.render(self.getPopoverTemplate(), data), + modal: false, + highlightModalTarget: true, + closeOnPageClick: true, + placement: 'bottom' + }) + } + + FilterWidget.prototype.loadOptions = function(scopeName) { + var $form = this.$el.closest('form'), + self = this, + data = { scopeName: scopeName } + + $form.request(this.options.optionsHandler, { + data: data, + success: function(data) { + + if (self.scopeValues[scopeName]) + return + + self.scopeValues[scopeName] = data.options + + // Do not render if scope has changed + if (scopeName != self.activeScopeName) + return + + /* + * Inject available + */ + if (data.options.available) { + var container = $('#controlFilterPopover .filter-items > ul').empty() + self.addItemsToListElement(container, data.options.available) + } + + /* + * Inject active + */ + if (data.options.active) { + var container = $('#controlFilterPopover .filter-active-items > ul') + self.addItemsToListElement(container, data.options.active) + } + + } + }) + + } + + FilterWidget.prototype.filterAvailable = function(scopeName, available) { + if (this.activeScopeName != scopeName) + return + + if (!this.scopeValues[this.activeScopeName]) + return + + var + self = this, + filtered = [], + items = this.scopeValues[scopeName] + + /* + * Ensure any active items do not appear in the search results + */ + if (items.active.length) { + var compareFunc = function(a, b) { return a.id == b.id }, + inArrayFunc = function(elem, array, testFunc) { + var i = array.length + do { if (i-- === 0) return i } while (testFunc(array[i], elem)) + return i + } + + filtered = $.grep(available, function(item) { + return !inArrayFunc(item, items.active, compareFunc) + }) + } + else { + filtered = available + } + + var container = $('#controlFilterPopover .filter-items > ul').empty() + self.addItemsToListElement(container, filtered) + } + + FilterWidget.prototype.addItemsToListElement = function($ul, items) { + $.each(items, function(key, obj){ + var item = $('
  • ').data({ 'item-id': obj.id }) + .append($('').prop({ 'href': 'javascript:;',}).text(obj.name)) + + $ul.append(item) + }) + } + + FilterWidget.prototype.pushOptions = function(scopeName) { + if (!this.isActiveScopeDirty) + return + + var $form = this.$el.closest('form'), + data = { + scopeName: scopeName, + options: this.scopeValues[scopeName] + } + + $.oc.stripeLoadIndicator.show() + $form.request(this.options.updateHandler, { + data: data + }).always(function(){ + $.oc.stripeLoadIndicator.hide() + }) + } + + FilterWidget.prototype.checkboxToggle = function(scopeName, isChecked) { + var $form = this.$el.closest('form'), + data = { + scopeName: scopeName, + value: isChecked + } + + $.oc.stripeLoadIndicator.show() + $form.request(this.options.updateHandler, { + data: data + }).always(function(){ + $.oc.stripeLoadIndicator.hide() + }) + } + + + // FILTER WIDGET PLUGIN DEFINITION + // ============================ + + var old = $.fn.filterWidget + + $.fn.filterWidget = function (option) { + var args = arguments, + result + + this.each(function () { + var $this = $(this) + var data = $this.data('oc.filterwidget') + var options = $.extend({}, FilterWidget.DEFAULTS, $this.data(), typeof option == 'object' && option) + if (!data) $this.data('oc.filterwidget', (data = new FilterWidget(this, options))) + if (typeof option == 'string') result = data[option].call($this) + if (typeof result != 'undefined') return false + }) + + return result ? result : this + } + + $.fn.filterWidget.Constructor = FilterWidget + + // FILTER WIDGET NO CONFLICT + // ================= + + $.fn.filterWidget.noConflict = function () { + $.fn.filterWidget = old + return this + } + + // FILTER WIDGET DATA-API + // ============== + + $(document).render(function(){ + $('[data-control="filterwidget"]').filterWidget(); + }) + +}(window.jQuery); + diff --git a/modules/backend/widgets/filter/partials/_filter.htm b/modules/backend/widgets/filter/partials/_filter.htm index 552cc716a..35f867595 100644 --- a/modules/backend/widgets/filter/partials/_filter.htm +++ b/modules/backend/widgets/filter/partials/_filter.htm @@ -1,48 +1,9 @@ -
    +
    - - Categories: - all - - - - Statuses: - 2 - + makePartial('filter_scopes') ?>
    - - - - diff --git a/modules/backend/widgets/filter/partials/_filter_scopes.htm b/modules/backend/widgets/filter/partials/_filter_scopes.htm new file mode 100644 index 000000000..95bdf2f1d --- /dev/null +++ b/modules/backend/widgets/filter/partials/_filter_scopes.htm @@ -0,0 +1,3 @@ + + renderScopeElement($scope) ?> + diff --git a/modules/backend/widgets/filter/partials/_scope_checkbox.htm b/modules/backend/widgets/filter/partials/_scope_checkbox.htm new file mode 100644 index 000000000..d9ce4fe87 --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_checkbox.htm @@ -0,0 +1,7 @@ + +
    + value ? 'checked' : '' ?> /> + +
    diff --git a/modules/backend/widgets/filter/partials/_scope_group.htm b/modules/backend/widgets/filter/partials/_scope_group.htm new file mode 100644 index 000000000..fd3059a22 --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_group.htm @@ -0,0 +1,8 @@ + + + label) ?>: + value ? count($scope->value) : 'all' ?> + diff --git a/modules/backend/widgets/form/partials/_field_balloon-selector.htm b/modules/backend/widgets/form/partials/_field_balloon-selector.htm index fb38c7f32..3383261e6 100644 --- a/modules/backend/widgets/form/partials/_field_balloon-selector.htm +++ b/modules/backend/widgets/form/partials/_field_balloon-selector.htm @@ -5,7 +5,7 @@
    attributes) ?>>
      $text): ?> -
    • +
    diff --git a/modules/backend/widgets/form/partials/_field_switch.htm b/modules/backend/widgets/form/partials/_field_switch.htm index e0f4ff23c..28e8bca6d 100644 --- a/modules/backend/widgets/form/partials/_field_switch.htm +++ b/modules/backend/widgets/form/partials/_field_switch.htm @@ -1,6 +1,6 @@
    - + comment): ?>

    comment)) ?>

    diff --git a/modules/backend/widgets/grid/partials/_button_delete.htm b/modules/backend/widgets/grid/partials/_button_delete.htm index 5ca95b948..68dc9214c 100644 --- a/modules/backend/widgets/grid/partials/_button_delete.htm +++ b/modules/backend/widgets/grid/partials/_button_delete.htm @@ -2,5 +2,5 @@ href="javascript:;" class="btn btn-sm btn-default oc-icon-minus-square" onclick="$(this).closest('.datagrid-widget').find('[data-control=datagrid]').dataGrid('removeRow')"> - Delete Row + \ No newline at end of file diff --git a/modules/backend/widgets/grid/partials/_button_insert.htm b/modules/backend/widgets/grid/partials/_button_insert.htm index a911c4828..97af9a04b 100644 --- a/modules/backend/widgets/grid/partials/_button_insert.htm +++ b/modules/backend/widgets/grid/partials/_button_insert.htm @@ -2,5 +2,5 @@ href="javascript:;" class="btn btn-sm btn-default oc-icon-plus-square" onclick="$(this).closest('.datagrid-widget').find('[data-control=datagrid]').dataGrid('insertRow')"> - Insert Row + \ No newline at end of file diff --git a/modules/backend/widgets/lists/partials/_list_pagination.htm b/modules/backend/widgets/lists/partials/_list_pagination.htm index fa7c2a22d..9816fbd04 100644 --- a/modules/backend/widgets/lists/partials/_list_pagination.htm +++ b/modules/backend/widgets/lists/partials/_list_pagination.htm @@ -1,14 +1,16 @@
    - Displayed records: - of + + $pageFrom, 'to' => $pageTo, 'total' => $recordTotal])) ?> + 1): ?> + data-load-indicator="" + title=""> $pageCurrent): ?> + data-load-indicator="" + title="">
    \ No newline at end of file diff --git a/modules/backend/widgets/lists/partials/_setup_form.htm b/modules/backend/widgets/lists/partials/_setup_form.htm index 09858969d..0ae7b83eb 100644 --- a/modules/backend/widgets/lists/partials/_setup_form.htm +++ b/modules/backend/widgets/lists/partials/_setup_form.htm @@ -1,13 +1,10 @@