diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae33f12bf..1f463a17f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+* **Build 125** (2014-07-24)
+ - Theme support added.
+ - Added new Theme picker to the backend via Settings > Front-end theme
+ - New shorthand method for `$this->getClassExtension('Backend.Behaviors.FormController')` becomes `$this->asExtension('FormController')`.
+ - Buttons inside a popup support new `data-popup-load-indicator` attribute.
+ - Added a new config item to disable core updates completely (see config cms.disableCoreUpdates).
+ - Added a unique alternate favicon to the Back-end area.
+
+* **Build 124** (2014-07-17)
+ - Improvements to Twig functions and filters.
+ - URL, HTML and Form helpers are now available in Twig.
+ - The DataGrid form widget has been moved to a standard widget called Grid.
+
* **Build 122** (2014-07-15)
- Restyled the CMS tabs
diff --git a/app/config/cms.php b/app/config/cms.php
index 239b96556..a0d940450 100644
--- a/app/config/cms.php
+++ b/app/config/cms.php
@@ -33,6 +33,20 @@ return array(
*/
'disablePlugins' => [],
+ /*
+ |--------------------------------------------------------------------------
+ | Prevents application updates
+ |--------------------------------------------------------------------------
+ |
+ | If using composer or git to download updates to the core files, set this
+ | value to 'true' to prevent the update gateway from trying to download
+ | these files again as part of the application update process. Plugins
+ | and themes will still be downloaded.
+ |
+ */
+
+ 'disableCoreUpdates' => false,
+
/*
|--------------------------------------------------------------------------
| Back-end URI prefix
diff --git a/app/start/global.php b/app/start/global.php
index a5b2c6aff..1241255d8 100644
--- a/app/start/global.php
+++ b/app/start/global.php
@@ -48,7 +48,10 @@ Log::useFiles(storage_path().'/logs/system.log');
App::error(function(Exception $exception, $code)
{
- Log::error($exception);
+ /*
+ * October uses a custom error handler, see
+ * System\Classes\ErrorHandler::handleException
+ */
});
/*
diff --git a/modules/backend/ServiceProvider.php b/modules/backend/ServiceProvider.php
index 10ebed318..34aea94e1 100644
--- a/modules/backend/ServiceProvider.php
+++ b/modules/backend/ServiceProvider.php
@@ -82,7 +82,7 @@ class ServiceProvider extends ModuleServiceProvider
'category' => 'My Settings',
'icon' => 'icon-code',
'url' => Backend::URL('backend/editorpreferences'),
- 'sort' => 200,
+ 'order' => 600,
'context' => 'mysettings'
],
'backend_preferences' => [
@@ -91,7 +91,7 @@ class ServiceProvider extends ModuleServiceProvider
'category' => 'My Settings',
'icon' => 'icon-laptop',
'class' => 'Backend\Models\BackendPreferences',
- 'sort' => 200,
+ 'order' => 500,
'context' => 'mysettings'
],
'myaccount' => [
@@ -100,7 +100,7 @@ class ServiceProvider extends ModuleServiceProvider
'category' => 'My Settings',
'icon' => 'icon-user',
'url' => Backend::URL('backend/users/myaccount'),
- 'sort' => 200,
+ 'order' => 400,
'context' => 'mysettings'
],
]);
diff --git a/modules/backend/assets/css/october.css b/modules/backend/assets/css/october.css
index 674351cf2..d2e4438c2 100644
--- a/modules/backend/assets/css/october.css
+++ b/modules/backend/assets/css/october.css
@@ -7271,6 +7271,7 @@ body {
/* The html and body elements cannot have any padding or margin. */
}
body {
+ webkit-font-smoothing: antialiased;
background: #fafafa;
}
#layout-canvas {
@@ -7323,6 +7324,9 @@ body {
.layout > .layout-row > .layout-cell.min-size {
width: 0;
}
+.layout > .layout-row > .layout-cell.min-height {
+ height: 0;
+}
.layout > .layout-row > .layout-cell.center {
text-align: center;
}
@@ -7363,6 +7367,9 @@ body {
.layout > .layout-row > .layout-cell.min-size {
width: 0;
}
+.layout > .layout-row > .layout-cell.min-height {
+ height: 0;
+}
.layout > .layout-row > .layout-cell.center {
text-align: center;
}
@@ -7411,6 +7418,9 @@ body {
.layout > .layout-cell.min-size {
width: 0;
}
+.layout > .layout-cell.min-height {
+ height: 0;
+}
.layout > .layout-cell.center {
text-align: center;
}
@@ -10506,6 +10516,19 @@ html.cssanimations .cursor-loading-indicator.hide {
.dropdown-menu .dropdown-container > ul li.divider {
margin: 0;
}
+.dropdown-menu.pull-right .dropdown-container > ul:after {
+ left: auto;
+ right: 15px;
+}
+.dropdown-menu.pull-right .dropdown-container > ul:before {
+ left: auto;
+ right: 14px;
+}
+.dropdown-menu.pull-right .dropdown-container > ul li.first-item a:hover:after,
+.dropdown-menu.pull-right .dropdown-container > ul li.first-item a:focus:after {
+ left: auto;
+ right: 15px;
+}
.dropdown-menu.top .dropdown-container > ul:after {
content: '';
display: block;
@@ -10944,6 +10967,7 @@ body.dropdown-open .dropdown-overlay {
.control-tabs.content-tabs > .tab-content > .tab-pane {
padding-top: 0;
}
+.control-tabs.content-tabs > .tab-content > .tab-pane div.list-header,
.control-tabs.content-tabs > .tab-content > .tab-pane div.padded-container,
.control-tabs.content-tabs > .tab-content > .tab-pane div.toolbar-widget {
background: #ffffff;
@@ -11518,9 +11542,13 @@ ul.status-list li span.status.info {
.control-breadcrumb li:last-child:after {
content: '';
}
-.control-breadcrumb + .content-tabs {
+.control-breadcrumb + .content-tabs,
+.control-breadcrumb + .padded-container {
margin-top: -20px;
}
+.control-breadcrumb.no-bottom-margin {
+ margin-bottom: 0;
+}
body.slim-container .control-breadcrumb {
margin-left: 0;
margin-right: 0;
diff --git a/modules/backend/assets/images/favicon.png b/modules/backend/assets/images/favicon.png
new file mode 100644
index 000000000..037163135
Binary files /dev/null and b/modules/backend/assets/images/favicon.png differ
diff --git a/modules/backend/assets/js/october.loadindicator.js b/modules/backend/assets/js/october.loadindicator.js
index a5bcc2667..880c4a9ee 100644
--- a/modules/backend/assets/js/october.loadindicator.js
+++ b/modules/backend/assets/js/october.loadindicator.js
@@ -101,7 +101,7 @@
// LOADINDICATOR DATA-API
// ==============
-
+
$(document)
.on('ajaxPromise', '[data-load-indicator]', function() {
var
diff --git a/modules/backend/assets/js/october.popup.js b/modules/backend/assets/js/october.popup.js
index 6e7675dcf..a6749fd0d 100644
--- a/modules/backend/assets/js/october.popup.js
+++ b/modules/backend/assets/js/october.popup.js
@@ -38,6 +38,11 @@
this.$modal = this.$target.modal({ show: false, backdrop: false, keyboard: this.options.keyboard })
this.isAjax = this.options.handler || this.options.ajax
+ /*
+ * Duplicate the popup reference on the .control-popup container
+ */
+ this.$target.data('oc.popup', this)
+
/*
* Hook in to BS Modal events
*/
@@ -50,7 +55,7 @@
setTimeout(function() { self.$content.empty() }, 500)
}
})
-
+
this.$modal.on('show.bs.modal', function(){
self.isOpen = true
self.setBackdrop(true)
@@ -156,7 +161,7 @@
this.$backdrop.addClass('in')
- this.$backdrop.append($('
'))
+ this.$backdrop.append($(''))
}
else if (!val && this.$backdrop) {
this.$backdrop.remove()
@@ -168,15 +173,24 @@
if (!this.$backdrop)
return;
- var self = this;
+ var self = this
if (val) {
setTimeout(function(){ self.$backdrop.addClass('loading'); }, 100)
- }
+ }
else {
this.$backdrop.removeClass('loading');
}
}
+ Popup.prototype.hideLoading = function(val) {
+ this.setLoading(false)
+
+ // Wait for animations to complete
+ var self = this
+ setTimeout(function() { self.setBackdrop(false) }, 250)
+ setTimeout(function() { self.hide() }, 500)
+ }
+
Popup.prototype.triggerEvent = function(eventName, params) {
if (!params)
params = [this.$el, this.$modal]
@@ -265,4 +279,15 @@
return false
});
+ $(document)
+ .on('ajaxPromise', '[data-popup-load-indicator]', function() {
+ $(this).closest('.control-popup').removeClass('in').popup('setLoading', true)
+ })
+ .on('ajaxFail', '[data-popup-load-indicator]', function() {
+ $(this).closest('.control-popup').addClass('in').popup('setLoading', false)
+ })
+ .on('ajaxDone', '[data-popup-load-indicator]', function() {
+ $(this).closest('.control-popup').popup('hideLoading')
+ })
+
}(window.jQuery);
diff --git a/modules/backend/assets/js/october.triggerapi.js b/modules/backend/assets/js/october.triggerapi.js
index 629de2372..aa5b811da 100644
--- a/modules/backend/assets/js/october.triggerapi.js
+++ b/modules/backend/assets/js/october.triggerapi.js
@@ -8,11 +8,14 @@
* Supported data attributes:
* - data-trigger-type, values: display, hide, enable, disable
* - data-trigger: a CSS selector for elements that trigger the action (checkboxes)
- * - data-trigger-condition, values: checked (more conditions to add later) - determines the condition the elements
- * specified in the data-trigger should satisfy in order the condition to be considered as "true".
+ * - data-trigger-condition, values:
+ * - checked: determines the condition the elements specified in the data-trigger
+ * should satisfy in order the condition to be considered as "true".
+ * - value[somevalue]: determines if the value of data-trigger equals the specified value (somevalue)
+ * the condition is considered "true".
*
* Example:
*
@@ -28,7 +31,7 @@
var TriggerOn = function (element, options) {
var $el = this.$el = $(element);
-
+
this.options = options || {};
if (this.options.triggerCondition === false)
@@ -40,10 +43,20 @@
if (this.options.triggerType === false)
throw new Error('Trigger type is not specified.')
- if (this.options.triggerCondition == 'checked')
+ this.triggerCondition = this.options.triggerCondition
+
+ if (this.options.triggerCondition.indexOf('value') == 0) {
+ var match = this.options.triggerCondition.match(/[^[\]]+(?=])/g)
+ if (match) {
+ this.triggerConditionValue = match
+ this.triggerCondition = 'value'
+ }
+ }
+
+ if (this.triggerCondition == 'checked' || this.triggerCondition == 'value')
$(document).on('change', this.options.trigger, $.proxy(this.onConditionChanged, this))
- var self = this;
+ var self = this
$el.on('oc.triggerOn.update', function(e){
e.stopPropagation()
self.onConditionChanged()
@@ -53,8 +66,12 @@
}
TriggerOn.prototype.onConditionChanged = function() {
- if (this.options.triggerCondition == 'checked')
- this.updateTarget($(this.options.trigger + ':checked').length > 0);
+ if (this.triggerCondition == 'checked') {
+ this.updateTarget($(this.options.trigger + ':checked').length > 0)
+ }
+ else if (this.triggerCondition == 'value') {
+ this.updateTarget($(this.options.trigger).val() == this.triggerConditionValue)
+ }
}
TriggerOn.prototype.updateTarget = function(status) {
diff --git a/modules/backend/assets/less/controls/breadcrumb.less b/modules/backend/assets/less/controls/breadcrumb.less
index 48316b278..2e3589625 100644
--- a/modules/backend/assets/less/controls/breadcrumb.less
+++ b/modules/backend/assets/less/controls/breadcrumb.less
@@ -5,8 +5,8 @@
margin: -20px -20px 20px -20px;
background-color: @color-breadcrumb-background;
- ul {
- padding: 0;
+ ul {
+ padding: 0;
margin: 0;
}
@@ -40,9 +40,13 @@
}
}
- + .content-tabs {
+ + .content-tabs, + .padded-container {
margin-top: -20px;
}
+
+ &.no-bottom-margin {
+ margin-bottom: 0;
+ }
}
body.slim-container {
@@ -50,4 +54,4 @@ body.slim-container {
margin-left: 0;
margin-right: 0;
}
-}
\ No newline at end of file
+}
diff --git a/modules/backend/assets/less/controls/dropdown.less b/modules/backend/assets/less/controls/dropdown.less
index 4b3ddadc3..1dbf70f34 100644
--- a/modules/backend/assets/less/controls/dropdown.less
+++ b/modules/backend/assets/less/controls/dropdown.less
@@ -91,8 +91,29 @@
}
}
+ &.pull-right {
+ .dropdown-container > ul {
+ &:after {
+ left: auto;
+ right: 15px;
+ }
+ &:before {
+ left: auto;
+ right: 14px;
+ }
+ li.first-item a {
+ &:hover, &:focus {
+ &:after {
+ left: auto;
+ right: 15px;
+ }
+ }
+ }
+ }
+ }
+
&.top {
- .dropdown-container {
+ .dropdown-container {
> ul {
&:after {
.triangle(down, 15px, 8px, @dropdown-bg);
@@ -110,7 +131,7 @@
}
}
-.touch .dropdown-menu .dropdown-container > ul li {
+.touch .dropdown-menu .dropdown-container > ul li {
a:hover {
color: @dropdown-link-color;
background: white;
diff --git a/modules/backend/assets/less/controls/popup.less b/modules/backend/assets/less/controls/popup.less
index f9969e44c..b5ebd9051 100644
--- a/modules/backend/assets/less/controls/popup.less
+++ b/modules/backend/assets/less/controls/popup.less
@@ -64,7 +64,7 @@
background-color: rgba(0,0,0,.2);
.opacity(1);
- .popup-loading-indicator {
+ .popup-loading-indicator {
display: block;
width: 100px;
height: 100px;
diff --git a/modules/backend/assets/less/controls/tab.less b/modules/backend/assets/less/controls/tab.less
index 843bf7ca6..5dee7990f 100644
--- a/modules/backend/assets/less/controls/tab.less
+++ b/modules/backend/assets/less/controls/tab.less
@@ -293,6 +293,7 @@
> .tab-content > .tab-pane {
padding-top: 0;
+ div.list-header,
div.padded-container,
div.toolbar-widget {
background: @color-tab-content-active-bg;
diff --git a/modules/backend/assets/less/layout/layout.less b/modules/backend/assets/less/layout/layout.less
index fb23adf52..4e7102586 100644
--- a/modules/backend/assets/less/layout/layout.less
+++ b/modules/backend/assets/less/layout/layout.less
@@ -28,6 +28,7 @@ body {
}
body {
+ webkit-font-smoothing: antialiased;
background: @color-body-bg;
}
@@ -68,6 +69,10 @@ body {
width: 0;
}
+ &.min-height {
+ height: 0;
+ }
+
&.center {
text-align: center;
}
diff --git a/modules/backend/behaviors/UserPreferencesModel.php b/modules/backend/behaviors/UserPreferencesModel.php
index a05a8ada1..bc186a983 100644
--- a/modules/backend/behaviors/UserPreferencesModel.php
+++ b/modules/backend/behaviors/UserPreferencesModel.php
@@ -70,9 +70,6 @@ class UserPreferencesModel extends SettingsModel
*/
public function beforeModelSave()
{
- // Purge the field values from the attributes
- $this->model->attributes = array_diff_key($this->model->attributes, $this->fieldValues);
-
$preferences = UserPreferences::forUser();
list($namespace, $group, $item) = $preferences->parseKey($this->recordCode);
$this->model->item = $item;
diff --git a/modules/backend/classes/Controller.php b/modules/backend/classes/Controller.php
index 65faa48cd..366d2b403 100644
--- a/modules/backend/classes/Controller.php
+++ b/modules/backend/classes/Controller.php
@@ -379,7 +379,7 @@ class Controller extends Extendable
return Response::make(Lang::get('backend::lang.model.mass_assignment_failed', ['attribute' => $ex->getMessage()]), 500);
}
catch (Exception $ex) {
- return Response::make($ex->getMessage(), 500);
+ return Response::make(sprintf('"%s" on line %s of %s', $ex->getMessage(), $ex->getLine(), $ex->getFile()), 500);
}
}
@@ -494,8 +494,11 @@ class Controller extends Extendable
* @param array $params Extra parameters
* @return string
*/
- public function makeHintPartial($name, $partial, array $params = [])
+ public function makeHintPartial($name, $partial = null, array $params = [])
{
+ if (!$partial)
+ $partial = $name;
+
return $this->makeLayoutPartial('hint', [
'hintName' => $name,
'hintPartial' => $partial,
diff --git a/modules/backend/classes/FormField.php b/modules/backend/classes/FormField.php
index 29b7d5490..e14b2f481 100644
--- a/modules/backend/classes/FormField.php
+++ b/modules/backend/classes/FormField.php
@@ -254,11 +254,16 @@ class FormField
/**
* Returns a value suitable for the field name property.
+ * @param string $arrayName Specify a custom array name
+ * @return string
*/
- public function getName()
+ public function getName($arrayName = null)
{
- if ($this->arrayName)
- return $this->arrayName.'['.implode('][', Str::evalHtmlArray($this->columnName)).']';
+ if ($arrayName === null)
+ $arrayName = $this->arrayName;
+
+ if ($arrayName)
+ return $arrayName.'['.implode('][', Str::evalHtmlArray($this->columnName)).']';
else
return $this->columnName;
}
diff --git a/modules/backend/layouts/_head.htm b/modules/backend/layouts/_head.htm
index 2d33dc454..8dceb3af1 100644
--- a/modules/backend/layouts/_head.htm
+++ b/modules/backend/layouts/_head.htm
@@ -1,5 +1,6 @@
+
= $this->pageTitle ?> | October CMS
diff --git a/modules/backend/widgets/Grid.php b/modules/backend/widgets/Grid.php
index 8105149fd..11de95c86 100644
--- a/modules/backend/widgets/Grid.php
+++ b/modules/backend/widgets/Grid.php
@@ -51,15 +51,25 @@ class Grid extends WidgetBase
protected $disableToolbar = false;
/**
- * @var mixed Array of data, or callable for data source.
+ * @var array Provided data set, cannot use with dataLocker or useDataSource.
*/
- protected $dataSource;
+ protected $data;
/**
- * @var string HTML element that can [re]store the grid data.
+ * @var string HTML element that can [re]store the grid data, cannot use with data or useDataSource.
*/
protected $dataLocker;
+ /**
+ * @var boolean Get data from AJAX callback (onDataSource), cannot use with data or dataLocker.
+ */
+ protected $useDataSource = false;
+
+ /**
+ * @var boolean Sends an AJAX callback (onDataChanged) any time a field is changed.
+ */
+ protected $monitorChanges = true;
+
/**
* Initialize the widget, called by the constructor and free from its parameters.
*/
@@ -70,8 +80,10 @@ class Grid extends WidgetBase
$this->allowInsert = $this->getConfig('allowInsert', $this->allowInsert);
$this->allowRemove = $this->getConfig('allowRemove', $this->allowRemove);
$this->disableToolbar = $this->getConfig('disableToolbar', $this->disableToolbar);
+ $this->data = $this->getConfig('data', $this->data);
$this->dataLocker = $this->getConfig('dataLocker', $this->dataLocker);
- $this->dataSource = $this->getConfig('dataSource', $this->dataSource);
+ $this->useDataSource = $this->getConfig('useDataSource', $this->useDataSource);
+ $this->monitorChanges = $this->getConfig('monitorChanges', $this->monitorChanges);
}
/**
@@ -97,7 +109,10 @@ class Grid extends WidgetBase
$this->vars['allowInsert'] = $this->allowInsert;
$this->vars['allowRemove'] = $this->allowRemove;
$this->vars['disableToolbar'] = $this->disableToolbar;
+ $this->vars['data'] = $this->data;
$this->vars['dataLocker'] = $this->dataLocker;
+ $this->vars['useDataSource'] = $this->useDataSource;
+ $this->vars['monitorChanges'] = $this->monitorChanges;
}
protected function makeToolbarWidget()
@@ -129,12 +144,32 @@ class Grid extends WidgetBase
return ['result' => $result];
}
- public function onDataSource()
+ public function onDataChanged()
{
- if ($this->dataLocker)
+ if (!$this->monitorChanges)
return;
- $result = $this->dataSource;
+ /*
+ * Changes array, each array item will contain:
+ * ['rowData' => [...], 'keyName' => 'changedColumn', 'oldValue' => 'was', 'newValue' => 'is']
+ */
+ $changes = post('changes');
+ $this->fireEvent('grid.dataChanged', [$changes]);
+ }
+
+ public function onDataSource()
+ {
+ if (!$this->useDataSource)
+ return;
+
+ $result = [];
+
+ if ($_result = $this->fireEvent('grid.dataSource', [], true))
+ $result = $_result;
+
+ if (!is_array($result))
+ $result = [];
+
return ['result' => $result];
}
diff --git a/modules/backend/widgets/form/assets/js/october.form.js b/modules/backend/widgets/form/assets/js/october.form.js
index 20dd440cf..4d20153aa 100644
--- a/modules/backend/widgets/form/assets/js/october.form.js
+++ b/modules/backend/widgets/form/assets/js/october.form.js
@@ -63,7 +63,6 @@
}
-
// FORM WIDGET PLUGIN DEFINITION
// ============================
@@ -97,7 +96,7 @@
// FORM WIDGET DATA-API
// ==============
-
+
$(document).render(function(){
$('[data-control="formwidget"]').formWidget();
})
diff --git a/modules/backend/widgets/form/partials/_field_number.htm b/modules/backend/widgets/form/partials/_field_number.htm
index a5f1a474f..9222b703f 100644
--- a/modules/backend/widgets/form/partials/_field_number.htm
+++ b/modules/backend/widgets/form/partials/_field_number.htm
@@ -11,6 +11,7 @@
class="form-control"
autocomplete="off"
maxlength="255"
+ pattern="\d+"
= HTML::attributes($field->attributes) ?>
/>
-
\ No newline at end of file
+
diff --git a/modules/backend/widgets/grid/assets/js/datagrid.js b/modules/backend/widgets/grid/assets/js/datagrid.js
index a1609cc3d..bf1728ee4 100644
--- a/modules/backend/widgets/grid/assets/js/datagrid.js
+++ b/modules/backend/widgets/grid/assets/js/datagrid.js
@@ -3,13 +3,16 @@
*
* Data attributes:
* - data-control="datagrid" - enables the plugin on an element
- * - data-option="value" - an option with a value
+ * - data-allow-remove="true" - allow rows to be removed
+ * - data-autocomplete-handler="onAutocomplete" - AJAX handler for autocomplete values
+ * - data-data-locker="input#locker" - Input element to store and restore grid data as JSON
+ * - data-source-handler="onGetData" - AJAX handler for obtaining grid data
*
* JavaScript API:
- * $('a#someElement').dataGrid({ option: 'value' })
+ * $('div#someElement').dataGrid({ option: 'value' })
*
- * Dependences:
- * - Some other plugin (filename.js)
+ * Dependences:
+ * - Handsontable (handsontable.js)
*/
+function ($) { "use strict";
@@ -22,13 +25,16 @@
this.options = options
this.$el = $(element)
+ this.columnHeaders = this.options.columnHeaders
this.staticWidths = this.options.columnWidths
this.gridInstance = null
this.columns = validateColumns(this.options.columns)
// Init
var handsontableOptions = {
- colHeaders: this.options.columnHeaders,
+ colHeaders: function(columnIndex) {
+ return self.columnHeaders[columnIndex]
+ },
colWidths: function(columnIndex) {
return self.staticWidths[columnIndex]
},
@@ -40,6 +46,19 @@
// rowHeaders: false,
// manualColumnMove: true,
// manualRowMove: true,
+ afterChange: function(changes, source) {
+ if (source === 'loadData')
+ return
+
+ /*
+ * changes - is a 2D array containing information about each of the edited cells
+ * [ [row, prop, oldVal, newVal], ... ].
+ *
+ * source - is one of the strings: "alter", "empty", "edit", "populateFromArray",
+ * "loadData", "autofill", "paste".
+ */
+ self.$el.trigger('datagrid.change', [changes, source])
+ },
fillHandle: false,
multiSelect: false,
removeRowPlugin: this.options.allowRemove
@@ -48,15 +67,24 @@
if (this.options.autoInsertRows)
handsontableOptions.minSpareRows = 1
- if (this.options.dataLocker) {
+ /*
+ * Data provided
+ */
+ if (this.options.data) {
+ handsontableOptions.data = this.options.data
+ }
+ /*
+ * Data from a data locker
+ */
+ else if (this.options.dataLocker) {
/*
* Event to update the data locker
*/
this.$dataLocker = $(this.options.dataLocker)
- handsontableOptions.afterChange = function(changes, source) {
+ self.$el.on('datagrid.change', function(event, eventData) {
if (!self.gridInstance) return
self.$dataLocker.val(JSON.stringify(self.getData()))
- }
+ })
/*
* Populate existing data
@@ -68,14 +96,44 @@
delete handsontableOptions.data
}
}
+ /*
+ * Data from an AJAX data source
+ */
else if (this.options.sourceHandler) {
- $.request(self.options.sourceHandler, {
- success: function(data, textStatus, jqXHR){
- self.gridInstance.loadData(data.result)
+ self.refreshDataSource()
+ }
+
+ /*
+ * Monitor for data changes
+ */
+ if (this.options.changeHandler) {
+ self.$el.on('datagrid.change', function(event, changes, source) {
+ var changeData = [];
+
+ $.each(changes, function(index, change){
+ var changeObj = {}
+ changeObj.keyName = change[1]
+ changeObj.oldValue = change[2]
+ changeObj.newValue = change[3]
+
+ if (changeObj.oldValue == changeObj.newValue)
+ return; // continue
+
+ changeObj.rowData = self.getDataAtRow(change[0])
+ changeData.push(changeObj)
+ })
+
+ if (changeData.length > 0) {
+ self.$el.request(self.options.changeHandler, {
+ data: { changes: changeData }
+ })
}
})
}
+ /*
+ * Create up Handson table and validate columns
+ */
this.$el.handsontable(handsontableOptions)
this.gridInstance = this.$el.handsontable('getInstance')
@@ -103,6 +161,9 @@
return columns
}
+ /*
+ * Auto complete
+ */
var autocompleteLastQuery = '',
autocompleteInterval = 300,
autocompleteInputTimer
@@ -137,7 +198,10 @@
}
DataGrid.DEFAULTS = {
+ data: null,
dataLocker: null,
+ sourceHandler: null,
+ changeHandler: null,
startRows: 1,
minRows: 1,
autoInsertRows: false,
@@ -145,11 +209,14 @@
columnWidths: null,
columns: null,
autocompleteHandler: null,
- sourceHandler: null,
allowRemove: true,
confirmMessage: 'Are you sure?'
}
+ DataGrid.prototype.setHeaderValue = function(index, value) {
+ this.columnHeaders[index] = value
+ }
+
DataGrid.prototype.getDataAtRow = function(row) {
if (!row && row !== 0)
row = this.getSelectedRow()
@@ -157,6 +224,18 @@
return $.extend(true, {}, this.gridInstance.getDataAtRow(row))
}
+ DataGrid.prototype.refreshDataSource = function() {
+ var self = this
+ this.$el.request(self.options.sourceHandler, {
+ success: function(data, textStatus, jqXHR){
+ self.setData(data.result)
+ }
+ })
+ }
+ DataGrid.prototype.setData = function(data) {
+ this.gridInstance.loadData(data)
+ }
+
DataGrid.prototype.getData = function() {
var self = this,
results = [],
diff --git a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.css b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.css
index d77fc1609..c47269b32 100644
--- a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.css
+++ b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.css
@@ -124,9 +124,6 @@
.handsontable tbody th:last-of-type {
border-right: 1px solid #e2e2e2 !important;
}
-.handsontable tbody td:first-of-type.currentRow {
- border-left: 3px solid #ff9933;
-}
.handsontable th.active {
/*background-color: #CCC;*/
color: #666;
diff --git a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.js b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.js
index 42ba7132d..6903dbcba 100644
--- a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.js
+++ b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.js
@@ -15,7 +15,12 @@
* - Use autocomplete plugin instead of typeahead
* - Custom checkboxes
* - Removed native scrollbars
- *
+ *
+ * @todo
+ * - Add a Column strategy for even distribution of 0 width columns
+ * - Replace dragdealer with October scrollbars
+ * - Explore mobile support, currently non-existent
+ *
*/
var Handsontable = { //class namespace
diff --git a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.less b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.less
index 7d297cb7f..717f5186b 100644
--- a/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.less
+++ b/modules/backend/widgets/grid/assets/vendor/handsontable/jquery.handsontable.less
@@ -147,9 +147,13 @@
border-right: 1px solid @color-handsontable-border !important;
}
- td:first-of-type.currentRow {
- border-left: 3px solid @color-list-stripe-active;
- }
+ //td:first-of-type {
+ // border-left: 3px solid transparent;
+ //}
+
+ // td:first-of-type.currentRow {
+ // border-left: 3px solid @color-list-stripe-active;
+ // }
}
th.active {
diff --git a/modules/backend/widgets/grid/partials/_grid.htm b/modules/backend/widgets/grid/partials/_grid.htm
index 149c01f65..b295084c0 100644
--- a/modules/backend/widgets/grid/partials/_grid.htm
+++ b/modules/backend/widgets/grid/partials/_grid.htm
@@ -4,20 +4,22 @@
data-data-locker="= $dataLocker ?>"
data-autocomplete-handler="= $this->getEventHandler('onAutocomplete') ?>"
- data-source-handler="= $this->getEventHandler('onDataSource') ?>"
+ data-data-locker="= $dataLocker ?>"
+ data-source-handler="= $this->getEventHandler('onDataSource') ?>"
+ data-change-handler="= $this->getEventHandler('onDataChanged') ?>"
>
diff --git a/modules/cms/ServiceProvider.php b/modules/cms/ServiceProvider.php
index 2a528b8b4..fede08556 100644
--- a/modules/cms/ServiceProvider.php
+++ b/modules/cms/ServiceProvider.php
@@ -6,6 +6,7 @@ use BackendMenu;
use BackendAuth;
use Backend\Classes\WidgetManager;
use October\Rain\Support\ModuleServiceProvider;
+use System\Classes\SettingsManager;
class ServiceProvider extends ModuleServiceProvider
{
@@ -89,6 +90,7 @@ class ServiceProvider extends ModuleServiceProvider
'cms.manage_pages' => ['label' => 'Manage pages', 'tab' => 'Cms'],
'cms.manage_layouts' => ['label' => 'Manage layouts', 'tab' => 'Cms'],
'cms.manage_partials' => ['label' => 'Manage partials', 'tab' => 'Cms'],
+ 'cms.manage_themes' => ['label' => 'Manage themes', 'tab' => 'Cms']
]);
});
@@ -99,6 +101,21 @@ class ServiceProvider extends ModuleServiceProvider
$manager->registerFormWidget('Cms\FormWidgets\Components');
});
+ /*
+ * Register settings
+ */
+ SettingsManager::instance()->registerCallback(function($manager){
+ $manager->registerSettingItems('October.Cms', [
+ 'theme' => [
+ 'label' => 'cms::lang.theme.settings_menu',
+ 'description' => 'cms::lang.theme.settings_menu_description',
+ 'category' => 'CMS',
+ 'icon' => 'icon-picture-o',
+ 'url' => Backend::URL('cms/themes'),
+ 'order' => 200
+ ]
+ ]);
+ });
}
/**
diff --git a/modules/cms/assets/css/october.theme-selector.css b/modules/cms/assets/css/october.theme-selector.css
new file mode 100644
index 000000000..92d8cca90
--- /dev/null
+++ b/modules/cms/assets/css/october.theme-selector.css
@@ -0,0 +1,112 @@
+.theme-selector-layout .layout-cell {
+ padding: 24px;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.theme-selector-layout .theme-thumbnail {
+ width: 288px;
+ background: #ecf0f1;
+ border-bottom: 1px solid #e3e7e9;
+}
+.theme-selector-layout .theme-thumbnail img {
+ opacity: 0.6;
+ filter: alpha(opacity=60);
+ width: 240px;
+}
+.theme-selector-layout .theme-description {
+ border-bottom: 1px solid #f2f3f4;
+}
+.theme-selector-layout .theme-description h3,
+.theme-selector-layout .theme-description p {
+ opacity: 0.6;
+ filter: alpha(opacity=60);
+}
+.theme-selector-layout .theme-description h3 {
+ margin: 0 0 25px 0;
+ font-size: 28px;
+ color: #2b3e50;
+ display: inline-block;
+}
+.theme-selector-layout .theme-description p.author {
+ font-size: 13px;
+ display: inline-block;
+ color: #808c8d;
+}
+.theme-selector-layout .theme-description p.description {
+ color: #2b3e50;
+ font-size: 14px;
+ line-height: 180%;
+ margin-bottom: 30px;
+}
+.theme-selector-layout .theme-description .controls button i.icon-star {
+ margin-right: 5px;
+ color: #f1a84e;
+ font-size: 16px;
+ vertical-align: middle;
+}
+.theme-selector-layout .layout-row.active .theme-thumbnail {
+ background: #bdc3c7;
+ border-bottom-color: #bdc3c7;
+}
+.theme-selector-layout .layout-row.active .thumbnail-container {
+ position: relative;
+}
+.theme-selector-layout .layout-row.active .thumbnail-container:after {
+ content: '';
+ display: block;
+ width: 0;
+ height: 0;
+ border-top: 14px solid transparent;
+ border-bottom: 14px solid transparent;
+ border-left: 15px solid #bdc3c7;
+ position: absolute;
+ right: -35px;
+ top: 50%;
+ margin-top: -14px;
+}
+.theme-selector-layout .layout-row.active .theme-description h3,
+.theme-selector-layout .layout-row:hover .theme-description h3,
+.theme-selector-layout .layout-row.active .theme-description p,
+.theme-selector-layout .layout-row:hover .theme-description p {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+.theme-selector-layout .layout-row.active .theme-thumbnail img,
+.theme-selector-layout .layout-row:hover .theme-thumbnail img {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+.theme-selector-layout .layout-row.last .theme-description,
+.theme-selector-layout .layout-row.last .theme-thumbnail {
+ border-bottom: none!important;
+}
+.theme-selector-layout .find-more-themes {
+ background: #ecf0f1;
+ color: #2b3e50;
+ text-decoration: none;
+ display: block;
+ padding: 20px;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+}
+.theme-selector-layout .find-more-themes:hover {
+ background: #1795f1;
+ color: white;
+}
+@media (max-width: 768px) {
+ .theme-selector-layout .layout-cell,
+ .theme-selector-layout .layout-row {
+ display: block!important;
+ width: auto!important;
+ height: auto!important;
+ }
+ .theme-selector-layout .theme-thumbnail img {
+ width: 100%;
+ }
+ .theme-selector-layout .layout-row.links .theme-thumbnail {
+ background: transparent;
+ padding: 0;
+ }
+}
diff --git a/modules/cms/assets/images/default-theme-preview.png b/modules/cms/assets/images/default-theme-preview.png
new file mode 100644
index 000000000..37d340d02
Binary files /dev/null and b/modules/cms/assets/images/default-theme-preview.png differ
diff --git a/modules/cms/assets/less/css/october.theme-selector.css b/modules/cms/assets/less/css/october.theme-selector.css
new file mode 100644
index 000000000..92d8cca90
--- /dev/null
+++ b/modules/cms/assets/less/css/october.theme-selector.css
@@ -0,0 +1,112 @@
+.theme-selector-layout .layout-cell {
+ padding: 24px;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.theme-selector-layout .theme-thumbnail {
+ width: 288px;
+ background: #ecf0f1;
+ border-bottom: 1px solid #e3e7e9;
+}
+.theme-selector-layout .theme-thumbnail img {
+ opacity: 0.6;
+ filter: alpha(opacity=60);
+ width: 240px;
+}
+.theme-selector-layout .theme-description {
+ border-bottom: 1px solid #f2f3f4;
+}
+.theme-selector-layout .theme-description h3,
+.theme-selector-layout .theme-description p {
+ opacity: 0.6;
+ filter: alpha(opacity=60);
+}
+.theme-selector-layout .theme-description h3 {
+ margin: 0 0 25px 0;
+ font-size: 28px;
+ color: #2b3e50;
+ display: inline-block;
+}
+.theme-selector-layout .theme-description p.author {
+ font-size: 13px;
+ display: inline-block;
+ color: #808c8d;
+}
+.theme-selector-layout .theme-description p.description {
+ color: #2b3e50;
+ font-size: 14px;
+ line-height: 180%;
+ margin-bottom: 30px;
+}
+.theme-selector-layout .theme-description .controls button i.icon-star {
+ margin-right: 5px;
+ color: #f1a84e;
+ font-size: 16px;
+ vertical-align: middle;
+}
+.theme-selector-layout .layout-row.active .theme-thumbnail {
+ background: #bdc3c7;
+ border-bottom-color: #bdc3c7;
+}
+.theme-selector-layout .layout-row.active .thumbnail-container {
+ position: relative;
+}
+.theme-selector-layout .layout-row.active .thumbnail-container:after {
+ content: '';
+ display: block;
+ width: 0;
+ height: 0;
+ border-top: 14px solid transparent;
+ border-bottom: 14px solid transparent;
+ border-left: 15px solid #bdc3c7;
+ position: absolute;
+ right: -35px;
+ top: 50%;
+ margin-top: -14px;
+}
+.theme-selector-layout .layout-row.active .theme-description h3,
+.theme-selector-layout .layout-row:hover .theme-description h3,
+.theme-selector-layout .layout-row.active .theme-description p,
+.theme-selector-layout .layout-row:hover .theme-description p {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+.theme-selector-layout .layout-row.active .theme-thumbnail img,
+.theme-selector-layout .layout-row:hover .theme-thumbnail img {
+ opacity: 1;
+ filter: alpha(opacity=100);
+}
+.theme-selector-layout .layout-row.last .theme-description,
+.theme-selector-layout .layout-row.last .theme-thumbnail {
+ border-bottom: none!important;
+}
+.theme-selector-layout .find-more-themes {
+ background: #ecf0f1;
+ color: #2b3e50;
+ text-decoration: none;
+ display: block;
+ padding: 20px;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+}
+.theme-selector-layout .find-more-themes:hover {
+ background: #1795f1;
+ color: white;
+}
+@media (max-width: 768px) {
+ .theme-selector-layout .layout-cell,
+ .theme-selector-layout .layout-row {
+ display: block!important;
+ width: auto!important;
+ height: auto!important;
+ }
+ .theme-selector-layout .theme-thumbnail img {
+ width: 100%;
+ }
+ .theme-selector-layout .layout-row.links .theme-thumbnail {
+ background: transparent;
+ padding: 0;
+ }
+}
diff --git a/modules/cms/assets/less/october.theme-selector.less b/modules/cms/assets/less/october.theme-selector.less
new file mode 100644
index 000000000..1da56c885
--- /dev/null
+++ b/modules/cms/assets/less/october.theme-selector.less
@@ -0,0 +1,139 @@
+@import "../../../backend/assets/less/core/boot.less";
+
+.theme-selector-layout {
+ .layout-cell {
+ padding: 24px;
+ .box-sizing(border-box);
+ }
+
+ .theme-thumbnail {
+ width: 288px;
+ background: #ecf0f1;
+ border-bottom: 1px solid #e3e7e9;
+
+ img {
+ .opacity(0.6);
+ width: 240px;
+ }
+ }
+
+ .theme-description {
+ border-bottom: 1px solid #f2f3f4;
+
+ h3, p {
+ .opacity(0.6);
+ }
+
+ h3 {
+ margin: 0 0 25px 0;
+ font-size: 28px;
+ color: #2b3e50;
+ display: inline-block;
+ }
+
+ p.author {
+ font-size: 13px;
+ display: inline-block;
+ color: #808c8d;
+ }
+
+ p.description {
+ color: #2b3e50;
+ font-size: 14px;
+ line-height: 180%;
+ margin-bottom: 30px;
+ }
+
+ .controls {
+ button i.icon-star {
+ margin-right: 5px;
+ color: #f1a84e;
+ font-size: 16px;
+ vertical-align: middle;
+ }
+ }
+ }
+
+ .layout-row.active {
+ .theme-thumbnail {
+ background: #bdc3c7;
+ border-bottom-color: #bdc3c7;
+
+ }
+
+ .thumbnail-container {
+ position: relative;
+
+ &:after {
+ .triangle(right, 15px, 28px, #bdc3c7);
+ position: absolute;
+ right: -35px;
+ top: 50%;
+ margin-top: -14px;
+ }
+ }
+ }
+
+ .layout-row {
+ &.active, &:hover {
+ .theme-description {
+ h3, p {
+ .opacity(1);
+ }
+ }
+
+ .theme-thumbnail {
+ img {
+ .opacity(1);
+ }
+ }
+ }
+
+ &.last {
+ .theme-description, .theme-thumbnail {
+ border-bottom: none!important;
+ }
+ }
+ }
+
+ .find-more-themes {
+ background: #ecf0f1;
+ color: #2b3e50;
+ text-decoration: none;
+ display: block;
+ padding: 20px;
+ .border-radius(4px);
+
+ &:hover {
+ background: @link-color;
+ color: white;
+ }
+ }
+}
+
+//
+// Screen specific
+//
+
+@media (max-width: @screen-sm) {
+ .theme-selector-layout {
+ .layout-cell, .layout-row {
+ display: block!important;
+ width: auto!important;
+ height: auto!important;
+ }
+
+ .theme-thumbnail {
+ img {
+ width: 100%;
+ }
+ }
+
+ .layout-row.links {
+ .theme-thumbnail {
+ background: transparent;
+ padding: 0;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/cms/classes/Controller.php b/modules/cms/classes/Controller.php
index 90828ae52..94017b350 100644
--- a/modules/cms/classes/Controller.php
+++ b/modules/cms/classes/Controller.php
@@ -93,6 +93,9 @@ class Controller extends BaseController
public function __construct($theme = null)
{
$this->theme = $theme ? $theme : Theme::getActiveTheme();
+ if (!$this->theme)
+ throw new CmsException(Lang::get('cms::lang.theme.active.not_found'));
+
$this->assetPath = Config::get('cms.themesDir').'/'.$this->theme->getDirName();
$this->router = new Router($this->theme);
$this->initTwigEnvironment();
diff --git a/modules/cms/classes/Router.php b/modules/cms/classes/Router.php
index 0499ce891..17964df10 100644
--- a/modules/cms/classes/Router.php
+++ b/modules/cms/classes/Router.php
@@ -205,8 +205,12 @@ class Router
*/
$pages = $this->theme->listPages();
$map = [];
- foreach ($pages as $page)
+ foreach ($pages as $page) {
+ if (!$page->url)
+ continue;
+
$map[] = ['file' => $page->getFileName(), 'pattern' => $page->url];
+ }
self::$urlMap = $map;
if ($cacheable)
diff --git a/modules/cms/classes/Theme.php b/modules/cms/classes/Theme.php
index 338cc9d89..ca7b92715 100644
--- a/modules/cms/classes/Theme.php
+++ b/modules/cms/classes/Theme.php
@@ -1,10 +1,15 @@
getPath($dirName);
return File::isDirectory($path);
@@ -84,8 +94,17 @@ class Theme
*/
public static function getActiveTheme()
{
+ $paramKey = 'cms::theme.active';
$activeTheme = Config::get('cms.activeTheme');
+ $dbResult = Parameters::findRecord($paramKey)
+ ->remember(1440, $paramKey)
+ ->pluck('value')
+ ;
+
+ if ($dbResult !== null)
+ $activeTheme = $dbResult;
+
$apiResult = Event::fire('cms.activeTheme', [], true);
if ($apiResult !== null)
$activeTheme = $apiResult;
@@ -93,7 +112,7 @@ class Theme
if (!strlen($activeTheme))
throw new SystemException(Lang::get('cms::lang.theme.active.not_set'));
- $theme = new self();
+ $theme = new static;
$theme->load($activeTheme);
if (!File::isDirectory($theme->getPath()))
return null;
@@ -101,6 +120,18 @@ class Theme
return $theme;
}
+ /**
+ * Sets the active theme.
+ * The active theme code is stored in the database and overrides the configuration cms.activeTheme parameter.
+ * @param string $code Specifies the active theme code.
+ */
+ public static function setActiveTheme($code)
+ {
+ $paramKey = 'cms::theme.active';
+ Parameters::set($paramKey, $code);
+ Cache::forget($paramKey);
+ }
+
/**
* Returns the edit theme.
* By default the edit theme is loaded from the cms.editTheme parameter,
@@ -123,11 +154,81 @@ class Theme
if (!strlen($editTheme))
throw new SystemException(Lang::get('cms::lang.theme.edit.not_set'));
- $theme = new self();
+ $theme = new static;
$theme->load($editTheme);
if (!File::isDirectory($theme->getPath()))
return null;
return $theme;
}
+
+ /**
+ * Returns a list of all themes.
+ * @return array Returns an array of the Theme objects.
+ */
+ public static function all()
+ {
+ $path = base_path().Config::get('cms.themesDir');
+
+ $it = new DirectoryIterator($path);
+ $it->rewind();
+
+ $result = [];
+ foreach ($it as $fileinfo) {
+ if (!$fileinfo->isDir() || $fileinfo->isDot())
+ continue;
+
+ $theme = new static;
+ $theme->load($fileinfo->getFilename());
+ $result[] = $theme;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Reads the theme.yaml file and returns the theme configuration values.
+ * @return array Returns the parsed configuration file values.
+ */
+ public function getConfig()
+ {
+ if ($this->configCache !== null)
+ return $this->configCache;
+
+ $path = $this->getPath().'/theme.yaml';
+ if (!File::exists($path))
+ return $this->configCache = [];
+
+ return $this->configCache = Yaml::parseFile($path);
+ }
+
+ /**
+ * Returns a value from the theme configuration file by its name.
+ * @param string $name Specifies the configuration parameter name.
+ * @param mixed $default Specifies the default value to return in case if the parameter doesn't exist in the configuration file.
+ * @return mixed Returns the parameter value or a default value
+ */
+ public function getConfigValue($name, $default = null)
+ {
+ $config = $this->getConfig();
+ if (isset($config[$name]))
+ return $config[$name];
+
+ return $default;
+ }
+
+ /**
+ * Returns the theme preview image URL.
+ * If the image file doesn't exist returns the placeholder image URL.
+ * @return string Returns the image URL.
+ */
+ public function getPreviewImageUrl()
+ {
+ $previewPath = '/assets/images/theme-preview.png';
+ $path = $this->getPath().$previewPath;
+ if (!File::exists($path))
+ return URL::asset('modules/cms/assets/images/default-theme-preview.png');
+
+ return URL::asset('themes/'.$this->getDirName().$previewPath);
+ }
}
diff --git a/modules/cms/controllers/Themes.php b/modules/cms/controllers/Themes.php
new file mode 100644
index 000000000..1fabb4f48
--- /dev/null
+++ b/modules/cms/controllers/Themes.php
@@ -0,0 +1,49 @@
+addCss('/modules/cms/assets/css/october.theme-selector.css', 'core');
+
+ $this->pageTitle = Lang::get('cms::lang.theme.settings_menu');
+ BackendMenu::setContext('October.System', 'system', 'settings');
+ }
+
+ public function index()
+ {
+
+ }
+
+ public function index_onSetActiveTheme()
+ {
+ CmsTheme::setActiveTheme(Input::get('theme'));
+
+ return [
+ '#theme-list' => $this->makePartial('theme_list')
+ ];
+ }
+}
\ No newline at end of file
diff --git a/modules/cms/controllers/themes/_theme_list.htm b/modules/cms/controllers/themes/_theme_list.htm
new file mode 100644
index 000000000..45f0fdce9
--- /dev/null
+++ b/modules/cms/controllers/themes/_theme_list.htm
@@ -0,0 +1,51 @@
+$theme):
+ $isLast = $index == $cnt-1;
+ $isActive = $activeTheme->getDirName() == $theme->getDirName();
+ $author = $theme->getConfigValue('author');
+?>
+
+
+
+
= e($theme->getConfigValue('name', $theme->getDirName())) ?>
+
+
by = e($author) ?>
+
+
= e($theme->getConfigValue('description', 'The theme description is not provided.')) ?>
+
+
+
+
+ = e(trans('cms::lang.theme.active_button')) ?>
+
+
+
+ = e(trans('cms::lang.theme.activate_button')) ?>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/cms/controllers/themes/index.htm b/modules/cms/controllers/themes/index.htm
new file mode 100644
index 000000000..7f5a47ef0
--- /dev/null
+++ b/modules/cms/controllers/themes/index.htm
@@ -0,0 +1,21 @@
+= Block::put('body') ?>
+
+
+
+
+ = Form::open(['onsubmit'=>'return false']) ?>
+
+ = $this->makePartial('theme_list') ?>
+
+ = Form::close() ?>
+
+
+
+= Block::endPut() ?>
\ No newline at end of file
diff --git a/modules/cms/lang/en/lang.php b/modules/cms/lang/en/lang.php
index 2b0cc6967..e83c70331 100644
--- a/modules/cms/lang/en/lang.php
+++ b/modules/cms/lang/en/lang.php
@@ -15,12 +15,18 @@ return [
'theme' => [
'active' => [
'not_set' => "The active theme is not set.",
+ 'not_found' => "The active theme is not found.",
],
'edit' => [
'not_set' => "The edit theme is not set.",
'not_found' => "The edit theme is not found.",
'not_match' => "The object you're trying to access doesn't belong to the theme being edited. Please reload the page."
- ]
+ ],
+ 'settings_menu' => 'Front-end theme',
+ 'settings_menu_description' => 'Preview the list of installed themes and select an active theme.',
+ 'find_more_themes' => 'Find more themes on OctoberCMS Theme Marketplace.',
+ 'activate_button' => 'Activate',
+ 'active_button' => 'Activate',
],
'page' => [
'not_found' => [
diff --git a/modules/cms/widgets/AssetList.php b/modules/cms/widgets/AssetList.php
index 4c4681ba4..97c5c4917 100644
--- a/modules/cms/widgets/AssetList.php
+++ b/modules/cms/widgets/AssetList.php
@@ -73,7 +73,6 @@ class AssetList extends WidgetBase
{
$this->addCss('css/assetlist.css', 'core');
$this->addJs('js/assetlist.js', 'core');
- $this->addJs('/modules/backend/widgets/form/assets/js/form.js', 'core');
}
/**
diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php
index 430934ab3..05438b4b9 100644
--- a/modules/system/ServiceProvider.php
+++ b/modules/system/ServiceProvider.php
@@ -229,7 +229,7 @@ class ServiceProvider extends ModuleServiceProvider
'category' => 'System',
'icon' => 'icon-envelope',
'class' => 'System\Models\MailSettings',
- 'sort' => 100
+ 'order' => 400,
],
'mail_templates' => [
'label' => 'system::lang.mail_templates.menu_label',
@@ -237,7 +237,7 @@ class ServiceProvider extends ModuleServiceProvider
'category' => 'System',
'icon' => 'icon-envelope-square',
'url' => Backend::url('system/mailtemplates'),
- 'sort' => 100
+ 'order' => 400,
],
]);
});
diff --git a/modules/system/assets/css/updates.css b/modules/system/assets/css/updates.css
index 61f9d8f20..03ea787f4 100644
--- a/modules/system/assets/css/updates.css
+++ b/modules/system/assets/css/updates.css
@@ -14,6 +14,10 @@
.control-updatelist h5:first-of-type {
border-top: none;
}
+.control-updatelist h5 i {
+ margin-right: 7px;
+ color: #405261;
+}
.control-updatelist h5 small {
text-transform: none;
float: right;
diff --git a/modules/system/assets/less/updates.less b/modules/system/assets/less/updates.less
index acb05939c..064effe5b 100644
--- a/modules/system/assets/less/updates.less
+++ b/modules/system/assets/less/updates.less
@@ -18,6 +18,11 @@
border-top: none;
}
+ i {
+ margin-right: 7px;
+ color: @color-text-title;
+ }
+
small {
text-transform: none;
float: right;
diff --git a/modules/system/behaviors/SettingsModel.php b/modules/system/behaviors/SettingsModel.php
index 669d636a5..cbff8d117 100644
--- a/modules/system/behaviors/SettingsModel.php
+++ b/modules/system/behaviors/SettingsModel.php
@@ -8,7 +8,7 @@ use System\Classes\ApplicationException;
*
* Usage:
*
- * In the model class definition:
+ * In the model class definition:
*
* public $implement = ['System.Behaviors.SettingsModel'];
* public $settingsCode = 'author_plugin_code';
@@ -200,4 +200,4 @@ class SettingsModel extends ModelBehavior
{
return $this->fieldConfig;
}
-}
+}
diff --git a/modules/system/classes/SettingsManager.php b/modules/system/classes/SettingsManager.php
index 69f9b5a52..0b7201cfd 100644
--- a/modules/system/classes/SettingsManager.php
+++ b/modules/system/classes/SettingsManager.php
@@ -36,7 +36,7 @@ class SettingsManager
'icon' => null,
'url' => null,
'permissions' => [],
- 'order' => 100,
+ 'order' => 500,
'context' => 'system',
];
diff --git a/modules/system/classes/UpdateManager.php b/modules/system/classes/UpdateManager.php
index f6b0d54c7..89197c48e 100644
--- a/modules/system/classes/UpdateManager.php
+++ b/modules/system/classes/UpdateManager.php
@@ -206,6 +206,16 @@ class UpdateManager
}
$result['plugins'] = $plugins;
+ /*
+ * Strip out themes that have been installed before
+ */
+ $themes = [];
+ foreach (array_get($result, 'themes', []) as $code => $info) {
+ if (!$this->isThemeInstalled($code))
+ $themes[$code] = $info;
+ }
+ $result['themes'] = $themes;
+
Parameters::set('system::update.count', array_get($result, 'update', 0));
return $result;
@@ -433,6 +443,57 @@ class UpdateManager
@unlink($filePath);
}
+ //
+ // Themes
+ //
+
+ /**
+ * Downloads a theme from the update server.
+ * @param string $name Theme name.
+ * @param string $hash Expected file hash.
+ * @return self
+ */
+ public function downloadTheme($name, $hash)
+ {
+ $fileCode = $name . $hash;
+ $this->requestServerFile('theme/get', $fileCode, $hash, ['name' => $name]);
+ }
+
+ /**
+ * Extracts a theme after it has been downloaded.
+ */
+ public function extractTheme($name, $hash)
+ {
+ $fileCode = $name . $hash;
+ $filePath = $this->getFilePath($fileCode);
+
+ if (!Zip::extract($filePath, $this->baseDirectory . '/themes/'))
+ throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath]));
+
+ @unlink($filePath);
+ }
+
+ /**
+ * Checks if a theme has ever been installed before.
+ * @param string $name Theme code
+ * @return boolean
+ */
+ public function isThemeInstalled($name)
+ {
+ return array_key_exists($name, Parameters::get('system::theme.history', []));
+ }
+
+ /**
+ * Flags a theme as being installed, so it is not downloaded twice.
+ * @param string $name Theme code
+ */
+ public function setThemeInstalled($name)
+ {
+ $history = Parameters::get('system::theme.history', []);
+ $history[$name] = Carbon::now()->timestamp;
+ Parameters::set('system::theme.history', $history);
+ }
+
//
// Notes
//
diff --git a/modules/system/console/OctoberUpdate.php b/modules/system/console/OctoberUpdate.php
index 8e733615e..8d95e71ef 100644
--- a/modules/system/console/OctoberUpdate.php
+++ b/modules/system/console/OctoberUpdate.php
@@ -1,6 +1,7 @@
output->writeln('Updating October... ');
$manager = UpdateManager::instance()->resetNotes();
$forceUpdate = $this->option('force');
- $pluginsOnly = $this->option('plugins');
- $coreOnly = $this->option('core');
+ /*
+ * Check for disabilities
+ */
+ $disableCore = $disablePlugins = $disableThemes = false;
+
+ if ($this->option('plugins')) {
+ $disableCore = true;
+ $disableThemes = true;
+ }
+
+ if ($this->option('core')) {
+ $disablePlugins = true;
+ $disableThemes = true;
+ }
+
+ if (Config::get('cms.disableCoreUpdates', false)) {
+ $disableCore = true;
+ }
+
+ /*
+ * Perform update
+ */
$updateList = $manager->requestUpdateList($forceUpdate);
$updates = (int)array_get($updateList, 'update', 0);
@@ -49,7 +70,7 @@ class OctoberUpdate extends Command
$this->output->writeln(sprintf('Found %s new %s! ', $updates, Str::plural('update', $updates)));
}
- $coreHash = $pluginsOnly ? null : array_get($updateList, 'core.hash');
+ $coreHash = $disableCore ? null : array_get($updateList, 'core.hash');
$coreBuild = array_get($updateList, 'core.build');
if ($coreHash) {
@@ -57,7 +78,7 @@ class OctoberUpdate extends Command
$manager->downloadCore($coreHash);
}
- $plugins = $coreOnly ? [] : array_get($updateList, 'plugins');
+ $plugins = $disablePlugins ? [] : array_get($updateList, 'plugins');
foreach ($plugins as $code => $plugin) {
$pluginName = array_get($plugin, 'name');
$pluginHash = array_get($plugin, 'hash');
diff --git a/modules/system/controllers/Updates.php b/modules/system/controllers/Updates.php
index 6b607b5b7..8c85a8f3f 100644
--- a/modules/system/controllers/Updates.php
+++ b/modules/system/controllers/Updates.php
@@ -4,6 +4,7 @@ use Str;
use Lang;
use File;
use Flash;
+use Config;
use Backend;
use Redirect;
use BackendMenu;
@@ -31,6 +32,11 @@ class Updates extends Controller
public $listConfig = ['list' => 'config_list.yaml', 'manage' => 'config_manage_list.yaml'];
+ /**
+ * @var boolean If set to true, core updates will not be downloaded or extracted.
+ */
+ protected $disableCoreUpdates = false;
+
public function __construct()
{
parent::__construct();
@@ -38,6 +44,8 @@ class Updates extends Controller
$this->addCss('/modules/system/assets/css/updates.css', 'core');
BackendMenu::setContext('October.System', 'system', 'updates');
+
+ $this->disableCoreUpdates = Config::get('cms.disableCoreUpdates', false);
}
/**
@@ -98,10 +106,12 @@ class Updates extends Controller
switch ($stepCode) {
case 'downloadCore':
+ if ($this->disableCoreUpdates) return;
$manager->downloadCore(post('hash'));
break;
case 'extractCore':
+ if ($this->disableCoreUpdates) return;
$manager->extractCore(post('hash'), post('build'));
break;
@@ -109,10 +119,18 @@ class Updates extends Controller
$manager->downloadPlugin(post('name'), post('hash'));
break;
+ case 'downloadTheme':
+ $manager->downloadTheme(post('name'), post('hash'));
+ break;
+
case 'extractPlugin':
$manager->extractPlugin(post('name'), post('hash'));
break;
+ case 'extractTheme':
+ $manager->extractTheme(post('name'), post('hash'));
+ break;
+
case 'completeUpdate':
$manager->update();
Flash::success(Lang::get('system::lang.updates.update_success'));
@@ -148,7 +166,8 @@ class Updates extends Controller
$this->vars['core'] = array_get($result, 'core', false);
$this->vars['hasUpdates'] = array_get($result, 'update', false);
- $this->vars['updateList'] = array_get($result, 'plugins', []);
+ $this->vars['pluginList'] = array_get($result, 'plugins', []);
+ $this->vars['themeList'] = array_get($result, 'themes', []);
}
catch (Exception $ex) {
$this->handleError($ex);
@@ -204,18 +223,20 @@ class Updates extends Controller
public function onApplyUpdates()
{
try {
- $plugins = post('plugins', []);
- if (!is_array($plugins))
- $plugins = [];
-
$coreHash = post('hash');
$coreBuild = post('build');
$core = [$coreHash, $coreBuild];
+ $plugins = post('plugins', []);
+ if (!is_array($plugins)) $plugins = [];
+
+ $themes = post('themes', []);
+ if (!is_array($themes)) $themes = [];
+
/*
* Update steps
*/
- $updateSteps = $this->buildUpdateSteps($core, $plugins);
+ $updateSteps = $this->buildUpdateSteps($core, $plugins, $themes);
/*
* Finish up
@@ -234,13 +255,16 @@ class Updates extends Controller
return $this->makePartial('execute');
}
- private function buildUpdateSteps($core, $plugins)
+ private function buildUpdateSteps($core, $plugins, $themes)
{
+ if (!is_array($core))
+ $core = [null, null];
+
if (!is_array($plugins))
$plugins = [];
- if (!is_array($core))
- $core = [null, null];
+ if (!is_array($themes))
+ $themes = [];
$updateSteps = [];
list($coreHash, $coreBuild) = $core;
@@ -265,6 +289,15 @@ class Updates extends Controller
];
}
+ foreach ($themes as $name => $hash) {
+ $updateSteps[] = [
+ 'code' => 'downloadTheme',
+ 'label' => Lang::get('system::lang.updates.theme_downloading', compact('name')),
+ 'name' => $name,
+ 'hash' => $hash
+ ];
+ }
+
/*
* Extract
*/
@@ -286,6 +319,15 @@ class Updates extends Controller
];
}
+ foreach ($themes as $name => $hash) {
+ $updateSteps[] = [
+ 'code' => 'extractTheme',
+ 'label' => Lang::get('system::lang.updates.theme_extracting', compact('name')),
+ 'name' => $name,
+ 'hash' => $hash
+ ];
+ }
+
return $updateSteps;
}
diff --git a/modules/system/controllers/updates/_update_list.htm b/modules/system/controllers/updates/_update_list.htm
index 8b0cf7e52..3ec0e7c5a 100644
--- a/modules/system/controllers/updates/_update_list.htm
+++ b/modules/system/controllers/updates/_update_list.htm
@@ -13,6 +13,7 @@
+
= e(trans('system::lang.system.name')) ?>
= e(trans('system::lang.updates.core_build_old', ['build'=>$core['old_build']])) ?>
@@ -26,8 +27,23 @@
- $plugin): ?>
+ $theme): ?>
+
+ = e(array_get($theme, 'name', 'Unknown')) ?>
+ = e(trans('system::lang.updates.theme_label')) ?>
+
+
+ = e(array_get($theme, 'version', 'v1.0.0')) ?>
+ = e(trans('system::lang.updates.theme_new_install')) ?>
+
+
+
+
+
+ $plugin): ?>
+
+
= e($plugin['name']) ?>
diff --git a/modules/system/lang/en/lang.php b/modules/system/lang/en/lang.php
index 66336b6ba..e337e01e7 100644
--- a/modules/system/lang/en/lang.php
+++ b/modules/system/lang/en/lang.php
@@ -137,6 +137,10 @@ return [
'plugin_version_none' => 'New plugin',
'plugin_version_old' => 'Current v:version',
'plugin_version_new' => 'v:version',
+ 'theme_label' => 'Theme',
+ 'theme_new_install' => 'New theme installation.',
+ 'theme_downloading' => 'Downloading theme: :name',
+ 'theme_extracting' => 'Unpacking theme: :name',
'update_label' => 'Update software',
'update_completing' => 'Finishing update process',
'update_loading' => 'Loading available updates...',
diff --git a/tests/unit/cms/classes/ThemeTest.php b/tests/unit/cms/classes/ThemeTest.php
index 436f033ae..28ae2fe63 100644
--- a/tests/unit/cms/classes/ThemeTest.php
+++ b/tests/unit/cms/classes/ThemeTest.php
@@ -17,7 +17,7 @@ class ThemeTest extends TestCase
$it->setMaxDepth(1);
$it->rewind();
- while($it->valid()) {
+ while ($it->valid()) {
if (!$it->isDot() && !$it->isDir() && $it->getExtension() == 'htm')
$result++;
diff --git a/themes/demo/assets/images/theme-preview.png b/themes/demo/assets/images/theme-preview.png
new file mode 100644
index 000000000..2f4c7d113
Binary files /dev/null and b/themes/demo/assets/images/theme-preview.png differ
diff --git a/themes/demo/theme.yaml b/themes/demo/theme.yaml
new file mode 100644
index 000000000..8ad40572a
--- /dev/null
+++ b/themes/demo/theme.yaml
@@ -0,0 +1,4 @@
+name: Demo
+description: Demo OctoberCMS theme. Demonstrates the basic concepts of the front-end theming: layouts, pages, partials, components, content blocks, AJAX framework.
+author: OctoberCMS
+authorUrl: 'http://octobercms.com'
\ No newline at end of file