diff --git a/Gemfile b/Gemfile
index 6dc920d055a..57e86b78176 100644
--- a/Gemfile
+++ b/Gemfile
@@ -93,8 +93,6 @@ gem 'thor', require: false
gem 'rest-client'
gem 'rinku'
gem 'sanitize'
-gem 'sass'
-gem 'sass-rails'
gem 'sidekiq'
gem 'sidekiq-statistic'
@@ -181,3 +179,5 @@ gem 'memory_profiler', require: false, platform: :mri
gem 'rmmseg-cpp', require: false
gem 'logster'
+
+gem 'sassc', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index dcb353855ae..a0f8c1af3d2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -105,7 +105,7 @@ GEM
rake
rake-compiler
fast_xs (0.8.0)
- ffi (1.9.17)
+ ffi (1.9.18)
flamegraph (0.9.5)
foreman (0.82.0)
thor (~> 0.19.1)
@@ -318,13 +318,11 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1)
- sass (3.2.19)
- sass-rails (5.0.4)
- railties (>= 4.0.0, < 5.0)
- sass (~> 3.1)
- sprockets (>= 2.8, < 4.0)
- sprockets-rails (>= 2.0, < 4.0)
- tilt (>= 1.1, < 3)
+ sass (3.4.23)
+ sassc (1.11.2)
+ bundler
+ ffi (~> 1.9.6)
+ sass (>= 3.3.0)
seed-fu (2.3.5)
activerecord (>= 3.1, < 4.3)
activesupport (>= 3.1, < 4.3)
@@ -462,8 +460,7 @@ DEPENDENCIES
rtlit
ruby-readability
sanitize
- sass
- sass-rails
+ sassc
seed-fu (~> 2.3.5)
shoulda
sidekiq
diff --git a/app/assets/javascripts/admin/adapters/theme.js.es6 b/app/assets/javascripts/admin/adapters/theme.js.es6
new file mode 100644
index 00000000000..df9c8830d1a
--- /dev/null
+++ b/app/assets/javascripts/admin/adapters/theme.js.es6
@@ -0,0 +1,20 @@
+import RestAdapter from 'discourse/adapters/rest';
+
+export default RestAdapter.extend({
+ basePath() {
+ return "/admin/";
+ },
+
+ afterFindAll(results) {
+ let map = {};
+ results.forEach(theme => {map[theme.id] = theme;});
+ results.forEach(theme => {
+ let mapped = theme.get("child_themes") || [];
+ mapped = mapped.map(t => map[t.id]);
+ theme.set("childThemes", mapped);
+ });
+ return results;
+ },
+
+ jsonMode: true
+});
diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6
index a03865c40ce..3c1d12ffe9c 100644
--- a/app/assets/javascripts/admin/components/ace-editor.js.es6
+++ b/app/assets/javascripts/admin/components/ace-editor.js.es6
@@ -14,6 +14,13 @@ export default Ember.Component.extend({
}
},
+ @observes('mode')
+ modeChanged() {
+ if (this._editor && !this._skipContentChangeEvent) {
+ this._editor.getSession().setMode("ace/mode/" + this.get('mode'));
+ }
+ },
+
_destroyEditor: function() {
if (this._editor) {
this._editor.destroy();
@@ -41,6 +48,7 @@ export default Ember.Component.extend({
editor.setTheme("ace/theme/chrome");
editor.setShowPrintMargin(false);
+ editor.setOptions({fontSize: "14px"});
editor.getSession().setMode("ace/mode/" + this.get('mode'));
editor.on('change', () => {
this._skipContentChangeEvent = true;
diff --git a/app/assets/javascripts/admin/components/color-input.js.es6 b/app/assets/javascripts/admin/components/color-input.js.es6
index 98d5f6e6bbd..005c4f5d4ba 100644
--- a/app/assets/javascripts/admin/components/color-input.js.es6
+++ b/app/assets/javascripts/admin/components/color-input.js.es6
@@ -1,3 +1,5 @@
+import {default as loadScript, loadCSS } from 'discourse/lib/load-script';
+
/**
An input field for a color.
@@ -6,19 +8,36 @@
@params valid is a boolean indicating if the input field is a valid color.
**/
export default Ember.Component.extend({
+ classNames: ['color-picker'],
hexValueChanged: function() {
var hex = this.get('hexValue');
+ let $text = this.$('input.hex-input');
+
if (this.get('valid')) {
- this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';');
+ $text.attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';');
+
+ if (this.get('pickerLoaded')) {
+ this.$('.picker').spectrum({color: "#" + this.get('hexValue')});
+ }
} else {
- this.$('input').attr('style', '');
+ $text.attr('style', '');
}
}.observes('hexValue', 'brightnessValue', 'valid'),
- _triggerHexChanged: function() {
- var self = this;
- Em.run.schedule('afterRender', function() {
- self.hexValueChanged();
+ didInsertElement() {
+ loadScript('/javascripts/spectrum.js').then(()=>{
+ loadCSS('/javascripts/spectrum.css').then(()=>{
+ Em.run.schedule('afterRender', ()=>{
+ this.$('.picker').spectrum({color: "#" + this.get('hexValue')})
+ .on("change.spectrum", (me, color)=>{
+ this.set('hexValue', color.toHexString().replace("#",""));
+ });
+ this.set('pickerLoaded', true);
+ });
+ });
});
- }.on('didInsertElement')
+ Em.run.schedule('afterRender', ()=>{
+ this.hexValueChanged();
+ });
+ }
});
diff --git a/app/assets/javascripts/admin/components/customize-link.js.es6 b/app/assets/javascripts/admin/components/customize-link.js.es6
deleted file mode 100644
index 0600f6b5cdb..00000000000
--- a/app/assets/javascripts/admin/components/customize-link.js.es6
+++ /dev/null
@@ -1,12 +0,0 @@
-import { getOwner } from 'discourse-common/lib/get-owner';
-
-export default Ember.Component.extend({
- router: function() {
- return getOwner(this).lookup('router:main');
- }.property(),
-
- active: function() {
- const id = this.get('customization.id');
- return this.get('router.url').indexOf(`/customize/css_html/${id}/css`) !== -1;
- }.property('router.url', 'customization.id')
-});
diff --git a/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6
new file mode 100644
index 00000000000..5c168760c7f
--- /dev/null
+++ b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6
@@ -0,0 +1,36 @@
+import {default as computed, observes} from "ember-addons/ember-computed-decorators";
+
+export default Ember.Component.extend({
+ init(){
+ this._super();
+ this.set("checkedInternal", this.get("checked"));
+ },
+
+ classNames: ['inline-edit'],
+
+ @observes("checked")
+ checkedChanged() {
+ this.set("checkedInternal", this.get("checked"));
+ },
+
+ @computed("labelKey")
+ label(key) {
+ return I18n.t(key);
+ },
+
+ @computed("checked", "checkedInternal")
+ changed(checked, checkedInternal) {
+ return (!!checked) !== (!!checkedInternal);
+ },
+
+ actions: {
+ cancelled(){
+ this.set("checkedInternal", this.get("checked"));
+ },
+
+ finished(){
+ this.set("checked", this.get("checkedInternal"));
+ this.sendAction();
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6
new file mode 100644
index 00000000000..e57606a489c
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6
@@ -0,0 +1,48 @@
+import computed from 'ember-addons/ember-computed-decorators';
+
+export default Ember.Controller.extend({
+ @computed("model.colors","onlyOverridden")
+ colors(allColors, onlyOverridden) {
+ if (onlyOverridden) {
+ return allColors.filter(color => color.get("overridden"));
+ } else {
+ return allColors;
+ }
+ },
+
+ actions: {
+
+ revert: function(color) {
+ color.revert();
+ },
+
+ undo: function(color) {
+ color.undo();
+ },
+
+ copy() {
+ var newColorScheme = Em.copy(this.get('model'), true);
+ newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + this.get('model.name'));
+ newColorScheme.save().then(()=>{
+ this.get('allColors').pushObject(newColorScheme);
+ this.replaceRoute('adminCustomize.colors.show', newColorScheme);
+ });
+ },
+
+ save: function() {
+ this.get('model').save();
+ },
+
+ destroy: function() {
+
+ return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
+ if (result) {
+ this.get('model').destroy().then(()=>{
+ this.get('allColors').removeObject(this.get('model'));
+ this.replaceRoute('adminCustomize.colors');
+ });
+ }
+ });
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6
index ae253aec843..87166e386f5 100644
--- a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6
@@ -1,10 +1,14 @@
-export default Ember.Controller.extend({
- onlyOverridden: false,
+import showModal from 'discourse/lib/show-modal';
+export default Ember.Controller.extend({
baseColorScheme: function() {
return this.get('model').findBy('is_base', true);
}.property('model.@each.id'),
+ baseColorSchemes: function() {
+ return this.get('model').filterBy('is_base', true);
+ }.property('model.@each.id'),
+
baseColors: function() {
var baseColorsHash = Em.Object.create({});
_.each(this.get('baseColorScheme.colors'), function(color){
@@ -13,99 +17,25 @@ export default Ember.Controller.extend({
return baseColorsHash;
}.property('baseColorScheme'),
- removeSelected() {
- this.get('model').removeObject(this.get('selectedItem'));
- this.set('selectedItem', null);
- },
-
- filterContent: function() {
- if (!this.get('selectedItem')) { return; }
-
- if (!this.get('onlyOverridden')) {
- this.set('colors', this.get('selectedItem.colors'));
- return;
- }
-
- const matches = [];
- _.each(this.get('selectedItem.colors'), function(color){
- if (color.get('overridden')) matches.pushObject(color);
- });
-
- this.set('colors', matches);
- }.observes('onlyOverridden'),
-
- updateEnabled: function() {
- var selectedItem = this.get('selectedItem');
- if (selectedItem.get('enabled')) {
- this.get('model').forEach(function(c) {
- if (c !== selectedItem) {
- c.set('enabled', false);
- c.startTrackingChanges();
- c.notifyPropertyChange('description');
- }
- });
- }
- },
-
actions: {
- selectColorScheme: function(colorScheme) {
- if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
- this.set('selectedItem', colorScheme);
- this.set('colors', colorScheme.get('colors'));
- colorScheme.set('savingStatus', null);
- colorScheme.set('selected', true);
- this.filterContent();
+
+ newColorSchemeWithBase(baseKey) {
+ const base = this.get('baseColorSchemes').findBy('base_scheme_id', baseKey);
+ const newColorScheme = Em.copy(base, true);
+ newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
+ newColorScheme.set('base_scheme_id', base.get('base_scheme_id'));
+ newColorScheme.save().then(()=>{
+ this.get('model').pushObject(newColorScheme);
+ newColorScheme.set('savingStatus', null);
+ this.replaceRoute('adminCustomize.colors.show', newColorScheme);
+ });
},
newColorScheme() {
- const newColorScheme = Em.copy(this.get('baseColorScheme'), true);
- newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
- this.get('model').pushObject(newColorScheme);
- this.send('selectColorScheme', newColorScheme);
- this.set('onlyOverridden', false);
+ showModal('admin-color-scheme-select-base', { model: this.get('baseColorSchemes'), admin: true});
},
- revert: function(color) {
- color.revert();
- },
- undo: function(color) {
- color.undo();
- },
-
- toggleEnabled: function() {
- var selectedItem = this.get('selectedItem');
- selectedItem.toggleProperty('enabled');
- selectedItem.save({enabledOnly: true});
- this.updateEnabled();
- },
-
- save: function() {
- this.get('selectedItem').save();
- this.updateEnabled();
- },
-
- copy(colorScheme) {
- var newColorScheme = Em.copy(colorScheme, true);
- newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name'));
- this.get('model').pushObject(newColorScheme);
- this.send('selectColorScheme', newColorScheme);
- },
-
- destroy: function() {
- var self = this,
- item = self.get('selectedItem');
-
- return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
- if (result) {
- if (item.get('newRecord')) {
- self.removeSelected();
- } else {
- item.destroy().then(function(){ self.removeSelected(); });
- }
- }
- });
- }
}
});
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6
deleted file mode 100644
index 47cf280ae6c..00000000000
--- a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6
+++ /dev/null
@@ -1,78 +0,0 @@
-import { url } from 'discourse/lib/computed';
-
-const sections = ['css', 'header', 'top', 'footer', 'head-tag', 'body-tag',
- 'mobile-css', 'mobile-header', 'mobile-top', 'mobile-footer',
- 'embedded-css'];
-
-const activeSections = {};
-sections.forEach(function(s) {
- activeSections[Ember.String.camelize(s) + "Active"] = Ember.computed.equal('section', s);
-});
-
-
-export default Ember.Controller.extend(activeSections, {
- maximized: false,
- section: null,
-
- previewUrl: url("model.key", "/?preview-style=%@"),
- downloadUrl: url('model.id', '/admin/site_customizations/%@'),
-
- mobile: function() {
- return this.get('section').indexOf('mobile-') === 0;
- }.property('section'),
-
- maximizeIcon: function() {
- return this.get('maximized') ? 'compress' : 'expand';
- }.property('maximized'),
-
- saveButtonText: function() {
- return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save');
- }.property('model.isSaving'),
-
- saveDisabled: function() {
- return !this.get('model.changed') || this.get('model.isSaving');
- }.property('model.changed', 'model.isSaving'),
-
- adminCustomizeCssHtml: Ember.inject.controller(),
-
- undoPreviewUrl: url('/?preview-style='),
- defaultStyleUrl: url('/?preview-style=default'),
-
- actions: {
- save() {
- this.get('model').saveChanges();
- },
-
- destroy() {
- return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
- if (result) {
- const model = this.get('model');
- model.destroyRecord().then(() => {
- this.get('adminCustomizeCssHtml').get('model').removeObject(model);
- this.transitionToRoute('adminCustomizeCssHtml');
- });
- }
- });
- },
-
- toggleMaximize: function() {
- this.toggleProperty('maximized');
- },
-
- toggleMobile: function() {
- const section = this.get('section');
-
- // Try to send to the same tab as before
- let dest;
- if (this.get('mobile')) {
- dest = section.replace('mobile-', '');
- if (sections.indexOf(dest) === -1) { dest = 'css'; }
- } else {
- dest = 'mobile-' + section;
- if (sections.indexOf(dest) === -1) { dest = 'mobile-css'; }
- }
- this.replaceRoute('adminCustomizeCssHtml.show', this.get('model.id'), dest);
- }
- }
-
-});
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6
new file mode 100644
index 00000000000..507ea7136ba
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6
@@ -0,0 +1,106 @@
+import { url } from 'discourse/lib/computed';
+import { default as computed } from 'ember-addons/ember-computed-decorators';
+
+export default Ember.Controller.extend({
+ maximized: false,
+ section: null,
+
+ targets: [
+ {id: 0, name: I18n.t('admin.customize.theme.common')},
+ {id: 1, name: I18n.t('admin.customize.theme.desktop')},
+ {id: 2, name: I18n.t('admin.customize.theme.mobile')}
+ ],
+
+ currentTarget: 0,
+
+ setTargetName: function(name) {
+ let target;
+ switch(name) {
+ case "common": target = 0; break;
+ case "desktop": target = 1; break;
+ case "mobile": target = 2; break;
+ }
+
+ this.set("currentTarget", target);
+ },
+
+ @computed("currentTarget")
+ currentTargetName(target) {
+ switch(parseInt(target)) {
+ case 0: return "common";
+ case 1: return "desktop";
+ case 2: return "mobile";
+ }
+ },
+
+ @computed("fieldName")
+ activeSectionMode(fieldName) {
+ return fieldName && fieldName.indexOf("scss") > -1 ? "css" : "html";
+ },
+
+ @computed("fieldName", "currentTargetName", "model")
+ activeSection: {
+ get(fieldName, target, model) {
+ return model.getField(target, fieldName);
+ },
+ set(value, fieldName, target, model) {
+ model.setField(target, fieldName, value);
+ return value;
+ }
+ },
+
+
+ @computed("currentTarget")
+ fields(target) {
+ let fields = [
+ "scss", "head_tag", "header", "after_header", "body_tag", "footer"
+ ];
+
+ if (parseInt(target) === 0) {
+ fields.push("embedded_scss");
+ }
+
+ return fields.map(name=>{
+ let hash = {
+ key: (`admin.customize.theme.${name}.text`),
+ name: name
+ };
+
+ if (name.indexOf("_tag") > 0) {
+ hash.icon = "file-text-o";
+ }
+
+ hash.title = I18n.t(`admin.customize.theme.${name}.title`);
+
+ return hash;
+ });
+ },
+
+ previewUrl: url('model.key', '/?preview-style=%@'),
+
+ maximizeIcon: function() {
+ return this.get('maximized') ? 'compress' : 'expand';
+ }.property('maximized'),
+
+ saveButtonText: function() {
+ return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save');
+ }.property('model.isSaving'),
+
+ saveDisabled: function() {
+ return !this.get('model.changed') || this.get('model.isSaving');
+ }.property('model.changed', 'model.isSaving'),
+
+ undoPreviewUrl: url('/?preview-style='),
+ defaultStyleUrl: url('/?preview-style=default'),
+
+ actions: {
+ save() {
+ this.get('model').saveChanges("theme_fields");
+ },
+
+ toggleMaximize: function() {
+ this.toggleProperty('maximized');
+ }
+ }
+
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6
new file mode 100644
index 00000000000..7ca35012419
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6
@@ -0,0 +1,163 @@
+import { default as computed } from 'ember-addons/ember-computed-decorators';
+import { url } from 'discourse/lib/computed';
+
+export default Ember.Controller.extend({
+
+ @computed("model.theme_fields.@each")
+ hasEditedFields(fields) {
+ return fields.any(f=>!Em.isBlank(f.value));
+ },
+
+ @computed('model.theme_fields.@each')
+ editedDescriptions(fields) {
+ let descriptions = [];
+ let description = target => {
+ let current = fields.filter(field => field.target === target && !Em.isBlank(field.value));
+ if (current.length > 0) {
+ let text = I18n.t('admin.customize.theme.'+target);
+ let localized = current.map(f=>I18n.t('admin.customize.theme.'+f.name + '.text'));
+ return text + ": " + localized.join(" , ");
+ }
+ };
+ ['common','desktop','mobile'].forEach(target=> {
+ descriptions.push(description(target));
+ });
+ return descriptions.reject(d=>Em.isBlank(d));
+ },
+
+ @computed("colorSchemeId", "model.color_scheme_id")
+ colorSchemeChanged(colorSchemeId, existingId) {
+ colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId);
+ return colorSchemeId !== existingId;
+ },
+
+ @computed("availableChildThemes", "model.childThemes.@each", "model", "allowChildThemes")
+ selectableChildThemes(available, childThemes, model, allowChildThemes) {
+ if (!allowChildThemes && (!childThemes || childThemes.length === 0)) {
+ return null;
+ }
+
+ let themes = [];
+ available.forEach(t=> {
+ if (!childThemes || (childThemes.indexOf(t) === -1)) {
+ themes.push(t);
+ };
+ });
+ return themes.length === 0 ? null : themes;
+ },
+
+ showSchemes: Em.computed.or("model.default", "model.user_selectable"),
+
+ @computed("allThemes", "allThemes.length", "model")
+ availableChildThemes(allThemes, count) {
+ if (count === 1) {
+ return null;
+ }
+
+ let excludeIds = [this.get("model.id")];
+
+ let themes = [];
+ allThemes.forEach(theme => {
+ if (excludeIds.indexOf(theme.get("id")) === -1) {
+ themes.push(theme);
+ }
+ });
+
+ return themes;
+ },
+
+ downloadUrl: url('model.id', '/admin/themes/%@'),
+
+ actions: {
+
+ updateToLatest() {
+ this.set("updatingRemote", true);
+ this.get("model").updateToLatest().finally(()=>{
+ this.set("updatingRemote", false);
+ });
+ },
+
+ checkForThemeUpdates() {
+ this.set("updatingRemote", true);
+ this.get("model").checkForUpdates().finally(()=>{
+ this.set("updatingRemote", false);
+ });
+ },
+
+ cancelChangeScheme() {
+ this.set("colorSchemeId", this.get("model.color_scheme_id"));
+ },
+ changeScheme(){
+ let schemeId = this.get("colorSchemeId");
+ this.set("model.color_scheme_id", schemeId === null ? null : parseInt(schemeId));
+ this.get("model").saveChanges("color_scheme_id");
+ },
+ startEditingName() {
+ this.set("oldName", this.get("model.name"));
+ this.set("editingName", true);
+ },
+ cancelEditingName() {
+ this.set("model.name", this.get("oldName"));
+ this.set("editingName", false);
+ },
+ finishedEditingName() {
+ this.get("model").saveChanges("name");
+ this.set("editingName", false);
+ },
+
+ editTheme() {
+ let edit = ()=>this.transitionToRoute('adminCustomizeThemes.edit', {model: this.get('model')});
+
+ if (this.get("model.remote_theme")) {
+ bootbox.confirm(I18n.t("admin.customize.theme.edit_confirm"), result => {
+ if (result) {
+ edit();
+ }
+ });
+ } else {
+ edit();
+ }
+ },
+
+ applyDefault() {
+ const model = this.get("model");
+ model.saveChanges("default").then(()=>{
+ if (model.get("default")) {
+ this.get("allThemes").forEach(theme=>{
+ if (theme !== model && theme.get('default')) {
+ theme.set("default", false);
+ }
+ });
+ }
+ });
+ },
+
+ applyUserSelectable() {
+ this.get("model").saveChanges("user_selectable");
+ },
+
+ addChildTheme() {
+ let themeId = parseInt(this.get("selectedChildThemeId"));
+ let theme = this.get("allThemes").findBy("id", themeId);
+ this.get("model").addChildTheme(theme);
+ },
+
+ removeChildTheme(theme) {
+ this.get("model").removeChildTheme(theme);
+ },
+
+ destroy() {
+ return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
+ if (result) {
+ const model = this.get('model');
+ model.destroyRecord().then(() => {
+ this.get('allThemes').removeObject(model);
+ this.transitionToRoute('adminCustomizeThemes');
+ });
+ }
+ });
+ },
+
+ }
+
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6
new file mode 100644
index 00000000000..94939fa09f9
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6
@@ -0,0 +1,14 @@
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+
+export default Ember.Controller.extend(ModalFunctionality, {
+
+ adminCustomizeColors: Ember.inject.controller(),
+
+ actions: {
+ selectBase() {
+ this.get('adminCustomizeColors')
+ .send('newColorSchemeWithBase', this.get('selectedBaseThemeId'));
+ this.send('closeModal');
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6
new file mode 100644
index 00000000000..d59d419ef53
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6
@@ -0,0 +1,35 @@
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+import { ajax } from 'discourse/lib/ajax';
+// import computed from 'ember-addons/ember-computed-decorators';
+
+export default Ember.Controller.extend(ModalFunctionality, {
+ local: Ember.computed.equal('selection', 'local'),
+ remote: Ember.computed.equal('selection', 'remote'),
+ selection: 'local',
+ adminCustomizeThemes: Ember.inject.controller(),
+
+ actions: {
+ importTheme() {
+
+ let options = {
+ type: 'POST'
+ };
+
+ if (this.get('local')) {
+ options.processData = false;
+ options.contentType = false;
+ options.data = new FormData();
+ options.data.append('theme', $('#file-input')[0].files[0]);
+ } else {
+ options.data = {remote: this.get('uploadUrl')};
+ }
+
+ ajax('/admin/themes/import', options).then(result=>{
+ const theme = this.store.createRecord('theme',result.theme);
+ this.get('adminCustomizeThemes').send('addTheme', theme);
+ this.send('closeModal');
+ });
+
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6
new file mode 100644
index 00000000000..82aba506a2c
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6
@@ -0,0 +1,13 @@
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+import { ajax } from 'discourse/lib/ajax';
+
+export default Ember.Controller.extend(ModalFunctionality, {
+ loadDiff() {
+ this.set('loading', true);
+ ajax('/admin/logs/staff_action_logs/' + this.get('model.id') + '/diff')
+ .then(diff=>{
+ this.set('loading', false);
+ this.set('diff', diff.side_by_side);
+ });
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6
deleted file mode 100644
index ca6ac31db1f..00000000000
--- a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6
+++ /dev/null
@@ -1,20 +0,0 @@
-import ModalFunctionality from 'discourse/mixins/modal-functionality';
-
-export default Ember.Controller.extend(ModalFunctionality, {
- previousSelected: Ember.computed.equal('selectedTab', 'previous'),
- newSelected: Ember.computed.equal('selectedTab', 'new'),
-
- onShow: function() {
- this.send("selectNew");
- },
-
- actions: {
- selectNew: function() {
- this.set('selectedTab', 'new');
- },
-
- selectPrevious: function() {
- this.set('selectedTab', 'previous');
- }
- }
-});
diff --git a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6
deleted file mode 100644
index 95537e305a3..00000000000
--- a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6
+++ /dev/null
@@ -1,7 +0,0 @@
-import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details";
-
-export default ChangeSiteCustomizationDetailsController.extend({
- onShow() {
- this.send("selectPrevious");
- }
-});
diff --git a/app/assets/javascripts/admin/models/color-scheme.js.es6 b/app/assets/javascripts/admin/models/color-scheme.js.es6
index 743c779d6c4..20bb348d570 100644
--- a/app/assets/javascripts/admin/models/color-scheme.js.es6
+++ b/app/assets/javascripts/admin/models/color-scheme.js.es6
@@ -9,18 +9,17 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
},
description: function() {
- return "" + this.name + (this.enabled ? ' (*)' : '');
+ return "" + this.name;
}.property(),
startTrackingChanges: function() {
this.set('originals', {
- name: this.get('name'),
- enabled: this.get('enabled')
+ name: this.get('name')
});
},
copy: function() {
- var newScheme = ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()});
+ var newScheme = ColorScheme.create({name: this.get('name'), can_edit: true, colors: Em.A()});
_.each(this.get('colors'), function(c){
newScheme.colors.pushObject(ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), default_hex: c.get('default_hex')}));
});
@@ -29,19 +28,15 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
changed: function() {
if (!this.originals) return false;
- if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true;
+ if (this.originals['name'] !== this.get('name')) return true;
if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true;
return false;
- }.property('name', 'enabled', 'colors.@each.changed', 'saving'),
+ }.property('name', 'colors.@each.changed', 'saving'),
disableSave: function() {
return !this.get('changed') || this.get('saving') || _.any(this.get('colors'), function(c) { return !c.get('valid'); });
}.property('changed'),
- disableEnable: function() {
- return !this.get('id') || this.get('saving');
- }.property('id', 'saving'),
-
newRecord: function() {
return (!this.get('id'));
}.property('id'),
@@ -53,11 +48,11 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
this.set('savingStatus', I18n.t('saving'));
this.set('saving',true);
- var data = { enabled: this.enabled };
+ var data = {};
if (!opts || !opts.enabledOnly) {
data.name = this.name;
-
+ data.base_scheme_id = this.get('base_scheme_id');
data.colors = [];
_.each(this.get('colors'), function(c) {
if (!self.id || c.get('changed')) {
@@ -78,8 +73,6 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
_.each(self.get('colors'), function(c) {
c.startTrackingChanges();
});
- } else {
- self.set('originals.enabled', data.enabled);
}
self.set('savingStatus', I18n.t('saved'));
self.set('saving', false);
@@ -96,30 +89,23 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
});
var ColorSchemes = Ember.ArrayProxy.extend({
- selectedItemChanged: function() {
- var selected = this.get('selectedItem');
- _.each(this.get('content'),function(i) {
- return i.set('selected', selected === i);
- });
- }.observes('selectedItem')
});
ColorScheme.reopenClass({
findAll: function() {
var colorSchemes = ColorSchemes.create({ content: [], loading: true });
- ajax('/admin/color_schemes').then(function(all) {
+ return ajax('/admin/color_schemes').then(function(all) {
_.each(all, function(colorScheme){
colorSchemes.pushObject(ColorScheme.create({
id: colorScheme.id,
name: colorScheme.name,
- enabled: colorScheme.enabled,
is_base: colorScheme.is_base,
+ base_scheme_id: colorScheme.base_scheme_id,
colors: colorScheme.colors.map(function(c) { return ColorSchemeColor.create({name: c.name, hex: c.hex, default_hex: c.default_hex}); })
}));
});
- colorSchemes.set('loading', false);
+ return colorSchemes;
});
- return colorSchemes;
}
});
diff --git a/app/assets/javascripts/admin/models/site-customization.js.es6 b/app/assets/javascripts/admin/models/site-customization.js.es6
deleted file mode 100644
index fe2176bf112..00000000000
--- a/app/assets/javascripts/admin/models/site-customization.js.es6
+++ /dev/null
@@ -1,31 +0,0 @@
-import RestModel from 'discourse/models/rest';
-
-const trackedProperties = [
- 'enabled', 'name', 'stylesheet', 'header', 'top', 'footer', 'mobile_stylesheet',
- 'mobile_header', 'mobile_top', 'mobile_footer', 'head_tag', 'body_tag', 'embedded_css'
-];
-
-function changed() {
- const originals = this.get('originals');
- if (!originals) { return false; }
- return _.some(trackedProperties, (p) => originals[p] !== this.get(p));
-}
-
-const SiteCustomization = RestModel.extend({
- description: function() {
- return "" + this.name + (this.enabled ? ' (*)' : '');
- }.property('selected', 'name', 'enabled'),
-
- changed: changed.property.apply(changed, trackedProperties.concat('originals')),
-
- startTrackingChanges: function() {
- this.set('originals', this.getProperties(trackedProperties));
- }.on('init'),
-
- saveChanges() {
- return this.save(this.getProperties(trackedProperties)).then(() => this.startTrackingChanges());
- },
-
-});
-
-export default SiteCustomization;
diff --git a/app/assets/javascripts/admin/models/staff-action-log.js.es6 b/app/assets/javascripts/admin/models/staff-action-log.js.es6
index 24ec4fbcc08..f108dd21d8b 100644
--- a/app/assets/javascripts/admin/models/staff-action-log.js.es6
+++ b/app/assets/javascripts/admin/models/staff-action-log.js.es6
@@ -39,7 +39,7 @@ const StaffActionLog = Discourse.Model.extend({
}.property('action_name'),
useCustomModalForDetails: function() {
- return _.contains(['change_site_customization', 'delete_site_customization'], this.get('action_name'));
+ return _.contains(['change_theme', 'delete_theme'], this.get('action_name'));
}.property('action_name')
});
diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6
new file mode 100644
index 00000000000..463d54b2586
--- /dev/null
+++ b/app/assets/javascripts/admin/models/theme.js.es6
@@ -0,0 +1,94 @@
+import RestModel from 'discourse/models/rest';
+import { default as computed } from 'ember-addons/ember-computed-decorators';
+
+const Theme = RestModel.extend({
+
+ @computed('theme_fields')
+ themeFields(fields) {
+
+ if (!fields) {
+ this.set('theme_fields', []);
+ return {};
+ }
+
+ let hash = {};
+ if (fields) {
+ fields.forEach(field=>{
+ hash[field.target + " " + field.name] = field;
+ });
+ }
+ return hash;
+ },
+
+ getField(target, name) {
+ let themeFields = this.get("themeFields");
+ let key = target + " " + name;
+ let field = themeFields[key];
+ return field ? field.value : "";
+ },
+
+ setField(target, name, value) {
+ this.set("changed", true);
+
+ let themeFields = this.get("themeFields");
+ let key = target + " " + name;
+ let field = themeFields[key];
+ if (!field) {
+ field = {name, target, value};
+ this.theme_fields.push(field);
+ themeFields[key] = field;
+ } else {
+ field.value = value;
+ }
+ },
+
+ @computed("childThemes.@each")
+ child_theme_ids(childThemes) {
+ if (childThemes) {
+ return childThemes.map(theme => Ember.get(theme, "id"));
+ }
+ },
+
+ removeChildTheme(theme) {
+ const childThemes = this.get("childThemes");
+ childThemes.removeObject(theme);
+ return this.saveChanges("child_theme_ids");
+ },
+
+ addChildTheme(theme){
+ let childThemes = this.get("childThemes");
+ childThemes.removeObject(theme);
+ childThemes.pushObject(theme);
+ return this.saveChanges("child_theme_ids");
+ },
+
+ @computed('name', 'default')
+ description: function(name, isDefault) {
+ if (isDefault) {
+ return I18n.t('admin.customize.theme.default_name', {name: name});
+ } else {
+ return name;
+ }
+ },
+
+ checkForUpdates() {
+ return this.save({remote_check: true})
+ .then(() => this.set("changed", false));
+ },
+
+ updateToLatest() {
+ return this.save({remote_update: true})
+ .then(() => this.set("changed", false));
+ },
+
+ changed: false,
+
+ saveChanges() {
+ const hash = this.getProperties.apply(this, arguments);
+ return this.save(hash)
+ .then(() => this.set("changed", false));
+ },
+
+});
+
+export default Theme;
diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6
new file mode 100644
index 00000000000..3f8bdcddcd5
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6
@@ -0,0 +1,18 @@
+export default Ember.Route.extend({
+
+ model(params) {
+ const all = this.modelFor('adminCustomize.colors');
+ const model = all.findBy('id', parseInt(params.scheme_id));
+ return model ? model : this.replaceWith('adminCustomize.colors.index');
+ },
+
+ serialize(model) {
+ return {scheme_id: model.get('id')};
+ },
+
+ setupController(controller, model) {
+ controller.set('model', model);
+ controller.set('allColors', this.modelFor('adminCustomize.colors'));
+ }
+});
+
diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6
index 8a47f1ba212..043e571271b 100644
--- a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6
@@ -6,9 +6,7 @@ export default Ember.Route.extend({
return ColorScheme.findAll();
},
- deactivate() {
- this._super();
- this.controllerFor('adminCustomizeColors').set('selectedItem', null);
- },
-
+ setupController(controller, model) {
+ controller.set("model", model);
+ }
});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6
deleted file mode 100644
index 7df829706fd..00000000000
--- a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6
+++ /dev/null
@@ -1,11 +0,0 @@
-export default Ember.Route.extend({
- model(params) {
- const all = this.modelFor('adminCustomizeCssHtml');
- const model = all.findBy('id', parseInt(params.site_customization_id));
- return model ? { model, section: params.section } : this.replaceWith('adminCustomizeCssHtml.index');
- },
-
- setupController(controller, hash) {
- controller.setProperties(hash);
- }
-});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6
deleted file mode 100644
index 5bbb4609596..00000000000
--- a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6
+++ /dev/null
@@ -1,26 +0,0 @@
-import showModal from 'discourse/lib/show-modal';
-import { popupAjaxError } from 'discourse/lib/ajax-error';
-
-export default Ember.Route.extend({
- model() {
- return this.store.findAll('site-customization');
- },
-
- actions: {
- importModal() {
- showModal('upload-customization');
- },
-
- newCustomization(obj) {
- obj = obj || {name: I18n.t("admin.customize.new_style")};
- const item = this.store.createRecord('site-customization');
-
- const all = this.modelFor('adminCustomizeCssHtml');
- const self = this;
- item.save(obj).then(function() {
- all.pushObject(item);
- self.transitionTo('adminCustomizeCssHtml.show', item.get('id'), 'css');
- }).catch(popupAjaxError);
- }
- }
-});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6
index 45cb6e21fbe..d8b1446dfb6 100644
--- a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6
@@ -1,5 +1,5 @@
export default Ember.Route.extend({
beforeModel() {
- this.transitionTo('adminCustomize.colors');
+ this.transitionTo('adminCustomizeThemes');
}
});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6
new file mode 100644
index 00000000000..aed0709e556
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6
@@ -0,0 +1,25 @@
+export default Ember.Route.extend({
+ model(params) {
+ const all = this.modelFor('adminCustomizeThemes');
+ const model = all.findBy('id', parseInt(params.theme_id));
+ return model ? { model, target: params.target, field_name: params.field_name} : this.replaceWith('adminCustomizeThemes.index');
+ },
+
+ serialize(wrapper) {
+ return {
+ model: wrapper.model,
+ target: wrapper.target || "common",
+ field_name: wrapper.field_name || "scss",
+ theme_id: wrapper.model.get("id")
+ };
+ },
+
+
+ setupController(controller, wrapper) {
+ controller.set("model", wrapper.model);
+ controller.setTargetName(wrapper.target || "common");
+ controller.set("fieldName", wrapper.field_name || "scss");
+ this.controllerFor("adminCustomizeThemes").set("editingTheme", true);
+ },
+
+});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6
new file mode 100644
index 00000000000..8e925ba6dd5
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6
@@ -0,0 +1,21 @@
+export default Ember.Route.extend({
+
+ serialize(model) {
+ return {theme_id: model.get('id')};
+ },
+
+ model(params) {
+ const all = this.modelFor('adminCustomizeThemes');
+ const model = all.findBy('id', parseInt(params.theme_id));
+ return model ? model : this.replaceWith('adminCustomizeTheme.index');
+ },
+
+ setupController(controller, model) {
+ controller.set("model", model);
+ const parentController = this.controllerFor("adminCustomizeThemes");
+ parentController.set("editingTheme", false);
+ controller.set("allThemes", parentController.get("model"));
+ controller.set("colorSchemes", parentController.get("model.extras.color_schemes"));
+ controller.set("colorSchemeId", model.get("color_scheme_id"));
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6
new file mode 100644
index 00000000000..6e5e19b7f51
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6
@@ -0,0 +1,36 @@
+import showModal from 'discourse/lib/show-modal';
+import { popupAjaxError } from 'discourse/lib/ajax-error';
+
+export default Ember.Route.extend({
+ model() {
+ return this.store.findAll('theme');
+ },
+
+ setupController(controller, model) {
+ this._super(controller, model);
+ // TODO ColorScheme to model
+ controller.set("editingTheme", false);
+ },
+
+ actions: {
+ importModal() {
+ showModal('admin-import-theme', {admin: true});
+ },
+
+ addTheme(theme) {
+ const all = this.modelFor('adminCustomizeThemes');
+ all.pushObject(theme);
+ this.transitionTo('adminCustomizeThemes.show', theme.get('id'));
+ },
+
+
+ newTheme(obj) {
+ obj = obj || {name: I18n.t("admin.customize.new_style")};
+ const item = this.store.createRecord('theme');
+
+ item.save(obj).then(() => {
+ this.send('addTheme', item);
+ }).catch(popupAjaxError);
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6
index 8b19ccb0dcf..698f90d77c1 100644
--- a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6
@@ -13,14 +13,9 @@ export default Discourse.Route.extend({
},
showCustomDetailsModal(model) {
- const modalName = (model.action_name + '_details').replace(/\_/g, "-");
-
- showModal(modalName, {
- model,
- admin: true,
- templateName: 'site-customization-change'
- });
- this.controllerFor('modal').set('modalClass', 'tabbed-modal log-details-modal');
+ let modal = showModal('admin-theme-change', { model, admin: true});
+ this.controllerFor('modal').set('modalClass', 'history-modal');
+ modal.loadDiff();
}
}
});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index bd38784bb7f..dd87207156a 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -15,10 +15,14 @@ export default function() {
});
this.route('adminCustomize', { path: '/customize', resetNamespace: true } ,function() {
- this.route('colors');
- this.route('adminCustomizeCssHtml', { path: 'css_html', resetNamespace: true }, function() {
- this.route('show', {path: '/:site_customization_id/:section'});
+ this.route('colors', function() {
+ this.route('show', {path: '/:scheme_id'});
+ });
+
+ this.route('adminCustomizeThemes', { path: 'themes', resetNamespace: true }, function() {
+ this.route('show', {path: '/:theme_id'});
+ this.route('edit', {path: '/:theme_id/:target/:field_name/edit'});
});
this.route('adminSiteText', { path: '/site_texts', resetNamespace: true }, function() {
diff --git a/app/assets/javascripts/admin/templates/components/customize-link.hbs b/app/assets/javascripts/admin/templates/components/customize-link.hbs
deleted file mode 100644
index dd3c4104c75..00000000000
--- a/app/assets/javascripts/admin/templates/components/customize-link.hbs
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- {{customization.description}}
-
-
diff --git a/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs
new file mode 100644
index 00000000000..3a651ad0dfe
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs
@@ -0,0 +1,8 @@
+
+{{#if changed}}
+ {{d-button action="finished" class="btn-primary btn-small submit-edit" icon="check"}}
+ {{d-button action="cancelled" class="btn-small cancel-edit" icon="times"}}
+{{/if}}
diff --git a/app/assets/javascripts/admin/templates/customize-colors-index.hbs b/app/assets/javascripts/admin/templates/customize-colors-index.hbs
new file mode 100644
index 00000000000..62bbb7a8fc3
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize-colors-index.hbs
@@ -0,0 +1 @@
+{{i18n 'admin.customize.colors.about'}}
diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs
new file mode 100644
index 00000000000..3c57058c9a3
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs
@@ -0,0 +1,53 @@
+
+
+
{{text-field class="style-name" value=model.name}}
+
+
+
+
+
+ {{model.savingStatus}}
+
+
+
+
+
+
+
+
+
+
+ {{#if colors.length}}
+
+
+
+ |
+ {{i18n 'admin.customize.color'}} |
+ |
+
+
+
+ {{#each colors as |c|}}
+
+
+ {{c.translatedName}}
+
+ {{c.description}}
+ |
+ {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} |
+
+
+
+ |
+
+ {{/each}}
+
+
+ {{else}}
+
{{i18n 'search.no_results'}}
+ {{/if}}
+
+
diff --git a/app/assets/javascripts/admin/templates/customize-colors.hbs b/app/assets/javascripts/admin/templates/customize-colors.hbs
index 62b489075d0..354598068b3 100644
--- a/app/assets/javascripts/admin/templates/customize-colors.hbs
+++ b/app/assets/javascripts/admin/templates/customize-colors.hbs
@@ -3,76 +3,16 @@
{{#each model as |scheme|}}
{{#unless scheme.is_base}}
- - {{scheme.description}}
+
+ -
+ {{#link-to 'adminCustomize.colors.show' scheme replace=true}}{{scheme.description}}{{/link-to}}
+
{{/unless}}
{{/each}}
-
+
-{{#if selectedItem}}
-
-
-
{{text-field class="style-name" value=selectedItem.name}}
-
-
-
-
-
-
- {{selectedItem.savingStatus}}
-
-
-
-
-
-
-
-
-
-
- {{#if colors.length}}
-
-
-
- |
- {{i18n 'admin.customize.color'}} |
- |
-
-
-
- {{#each colors as |c|}}
-
-
- {{c.translatedName}}
-
- {{c.description}}
- |
- {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} |
-
-
-
- |
-
- {{/each}}
-
-
- {{else}}
-
{{i18n 'search.no_results'}}
- {{/if}}
-
-
-{{else}}
- {{i18n 'admin.customize.colors.about'}}
-{{/if}}
+{{outlet}}
diff --git a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs b/app/assets/javascripts/admin/templates/customize-css-html-show.hbs
deleted file mode 100644
index 6a10c7ae0b2..00000000000
--- a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
- {{text-field class="style-name" value=model.name}}
-
{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}
-
-
-
- {{#if mobile}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-css' replace=true}}{{i18n "admin.customize.css"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-header' replace=true}}{{i18n "admin.customize.header"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-top' replace=true}}{{i18n "admin.customize.top"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-footer' replace=true}}{{i18n "admin.customize.footer"}}{{/link-to}}
- {{else}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'css' replace=true}}{{i18n "admin.customize.css"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'header' replace=true}}{{i18n "admin.customize.header"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'top' replace=true}}{{i18n "admin.customize.top"}}{{/link-to}}
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'footer' replace=true}}{{i18n "admin.customize.footer"}}{{/link-to}}
- -
- {{#link-to 'adminCustomizeCssHtml.show' model.id 'head-tag'}}
- {{fa-icon "file-text-o"}} {{i18n 'admin.customize.head_tag.text'}}
- {{/link-to}}
-
- -
- {{#link-to 'adminCustomizeCssHtml.show' model.id 'body-tag'}}
- {{fa-icon "file-text-o"}} {{i18n 'admin.customize.body_tag.text'}}
- {{/link-to}}
-
- - {{#link-to 'adminCustomizeCssHtml.show' model.id 'embedded-css' replace=true}}{{i18n "admin.customize.embedded_css"}}{{/link-to}}
- {{/if}}
- -
- {{fa-icon "mobile"}}
-
- -
-
-
-
-
-
-
-
-
- {{#if cssActive}}{{ace-editor content=model.stylesheet mode="scss"}}{{/if}}
- {{#if headerActive}}{{ace-editor content=model.header mode="html"}}{{/if}}
- {{#if topActive}}{{ace-editor content=model.top mode="html"}}{{/if}}
- {{#if footerActive}}{{ace-editor content=model.footer mode="html"}}{{/if}}
- {{#if headTagActive}}{{ace-editor content=model.head_tag mode="html"}}{{/if}}
- {{#if bodyTagActive}}{{ace-editor content=model.body_tag mode="html"}}{{/if}}
- {{#if embeddedCssActive}}{{ace-editor content=model.embedded_css mode="css"}}{{/if}}
- {{#if mobileCssActive}}{{ace-editor content=model.mobile_stylesheet mode="scss"}}{{/if}}
- {{#if mobileHeaderActive}}{{ace-editor content=model.mobile_header mode="html"}}{{/if}}
- {{#if mobileTopActive}}{{ace-editor content=model.mobile_top mode="html"}}{{/if}}
- {{#if mobileFooterActive}}{{ace-editor content=model.mobile_footer mode="html"}}{{/if}}
-
-
-
-
-
diff --git a/app/assets/javascripts/admin/templates/customize-css-html.hbs b/app/assets/javascripts/admin/templates/customize-css-html.hbs
deleted file mode 100644
index 73b8e22c9f2..00000000000
--- a/app/assets/javascripts/admin/templates/customize-css-html.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
{{i18n 'admin.customize.css_html.long_title'}}
-
- {{#each model as |c|}}
- {{customize-link customization=c}}
- {{/each}}
-
-
- {{d-button label="admin.customize.new" icon="plus" action="newCustomization" class="btn-primary"}}
- {{d-button action="importModal" icon="upload" label="admin.customize.import"}}
-
-
-{{outlet}}
diff --git a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs
new file mode 100644
index 00000000000..fbdafdd4139
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs
@@ -0,0 +1,62 @@
+
+
+
{{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}
+
+
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'common' fieldName replace=true title=field.title}}
+ {{i18n 'admin.customize.theme.common'}}
+ {{/link-to}}
+
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true title=field.title}}
+ {{i18n 'admin.customize.theme.desktop'}}
+ {{fa-icon 'desktop'}}
+ {{/link-to}}
+
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true title=field.title}}
+ {{i18n 'admin.customize.theme.mobile'}}
+ {{fa-icon 'mobile'}}
+ {{/link-to}}
+
+
+
+
+
+ {{#each fields as |field|}}
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id currentTargetName field.name replace=true title=field.title}}
+ {{#if field.icon}}{{fa-icon field.icon}} {{/if}}
+ {{i18n field.key}}
+ {{/link-to}}
+
+ {{/each}}
+ -
+
+
+
+
+
+
+
+
+
+ {{ace-editor content=activeSection mode=activeSectionMode}}
+
+
+
+
+
diff --git a/app/assets/javascripts/admin/templates/customize-css-html-index.hbs b/app/assets/javascripts/admin/templates/customize-themes-index.hbs
similarity index 100%
rename from app/assets/javascripts/admin/templates/customize-css-html-index.hbs
rename to app/assets/javascripts/admin/templates/customize-themes-index.hbs
diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
new file mode 100644
index 00000000000..5bbd9cb2ee1
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
@@ -0,0 +1,112 @@
+
+
+ {{#if editingName}}
+ {{text-field value=model.name autofocus="true"}}
+ {{d-button action="finishedEditingName" class="btn-primary btn-small submit-edit" icon="check"}}
+ {{d-button action="cancelEditingName" class="btn-small cancel-edit" icon="times"}}
+ {{else}}
+ {{model.name}} {{fa-icon "pencil"}}
+ {{/if}}
+
+
+ {{#if model.remote_theme}}
+
+ {{i18n "admin.customize.theme.about_theme"}}
+
+ {{#if model.remote_theme.license_url}}
+
+ {{i18n "admin.customize.theme.license"}} {{fa-icon "copyright"}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+ {{inline-edit-checkbox action="applyDefault" labelKey="admin.customize.theme.is_default" checked=model.default}}
+ {{inline-edit-checkbox action="applyUserSelectable" labelKey="admin.customize.theme.user_selectable" checked=model.user_selectable}}
+
+
+ {{#if showSchemes}}
+
{{i18n "admin.customize.theme.color_scheme"}}
+
{{i18n "admin.customize.theme.color_scheme_select"}}
+
{{combo-box content=colorSchemes
+ nameProperty="name"
+ value=colorSchemeId
+ valueAttribute="id"}}
+ {{#if colorSchemeChanged}}
+ {{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}}
+ {{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}}
+ {{/if}}
+
+ {{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}}
+ {{/if}}
+
+
{{i18n "admin.customize.theme.css_html"}}
+ {{#if hasEditedFields}}
+
+
{{i18n "admin.customize.theme.custom_sections"}}
+
+ {{#each editedDescriptions as |desc|}}
+ - {{desc}}
+ {{/each}}
+
+ {{else}}
+
+ {{i18n "admin.customize.theme.edit_css_html_help"}}
+
+ {{/if}}
+
+ {{#if model.remote_theme}}
+ {{#if model.remote_theme.commits_behind}}
+ {{#d-button action="updateToLatest" icon="download"}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}}
+ {{else}}
+ {{#d-button action="checkForThemeUpdates" icon="refresh"}}{{i18n "admin.customize.theme.check_for_updates"}}{{/d-button}}
+ {{/if}}
+ {{/if}}
+ {{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}}
+ {{#if model.remote_theme}}
+
+ {{#if updatingRemote}}
+ {{i18n 'admin.customize.theme.updating'}}
+ {{else}}
+ {{#if model.remote_theme.commits_behind}}
+ {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}}
+ {{else}}
+ {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}}
+ {{/if}}
+ {{/if}}
+
+ {{/if}}
+
+
+ {{#if availableChildThemes}}
+
{{i18n "admin.customize.theme.included_themes"}}
+ {{#unless model.childThemes.length}}
+
+
+
+ {{else}}
+
+ {{#each model.childThemes as |child|}}
+ - {{child.name}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit" icon="times"}}
+ {{/each}}
+
+ {{/unless}}
+ {{#if selectableChildThemes}}
+
{{combo-box content=selectableChildThemes
+ nameProperty="name"
+ value=selectedChildThemeId
+ valueAttribute="id"}}
+
+ {{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
+
+ {{/if}}
+ {{/if}}
+
+
{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}
+
+ {{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}}
+
diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs
new file mode 100644
index 00000000000..ce8f6820759
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize-themes.hbs
@@ -0,0 +1,24 @@
+{{#unless editingTheme}}
+
+
{{i18n 'admin.customize.theme.long_title'}}
+
+ {{#each model as |theme|}}
+ -
+ {{#link-to 'adminCustomizeThemes.show' theme replace=true}}
+ {{theme.name}}
+ {{#if theme.user_selectable}}
+ {{fa-icon "user"}}
+ {{/if}}
+ {{#if theme.default}}
+ {{fa-icon "asterisk"}}
+ {{/if}}
+ {{/link-to}}
+
+ {{/each}}
+
+
+ {{d-button label="admin.customize.new" icon="plus" action="newTheme" class="btn-primary"}}
+ {{d-button action="importModal" icon="upload" label="admin.customize.import"}}
+
+{{/unless}}
+{{outlet}}
diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs
index 7696b34811f..3065c09855c 100644
--- a/app/assets/javascripts/admin/templates/customize.hbs
+++ b/app/assets/javascripts/admin/templates/customize.hbs
@@ -1,7 +1,7 @@
{{#admin-nav}}
+ {{nav-item route='adminCustomizeThemes' label='admin.customize.theme.title'}}
{{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}}
- {{nav-item route='adminCustomizeCssHtml' label='admin.customize.css_html.title'}}
{{nav-item route='adminSiteText' label='admin.site_text.title'}}
{{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}}
{{nav-item route='adminUserFields' label='admin.user_fields.title'}}
diff --git a/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs
new file mode 100644
index 00000000000..5286bbf0b07
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs
@@ -0,0 +1,12 @@
+
+ {{#d-modal-body title="admin.customize.colors.select_base.title"}}
+ {{i18n "admin.customize.colors.select_base.description"}}
+ {{combo-box content=model
+ nameProperty="name"
+ value=selectedBaseThemeId
+ valueAttribute="base_scheme_id"}}
+ {{/d-modal-body}}
+
+
diff --git a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs
new file mode 100644
index 00000000000..7c4058d52da
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs
@@ -0,0 +1,27 @@
+{{#d-modal-body class='upload-selector' title="admin.customize.theme.import_theme"}}
+
+ {{radio-button name="upload" id="local" value="local" selection=selection}}
+
+ {{#if local}}
+
+
+ {{i18n 'admin.customize.theme.import_file_tip'}}
+
+ {{/if}}
+
+
+ {{radio-button name="upload" id="remote" value="remote" selection=selection}}
+
+ {{#if remote}}
+
+ {{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}}
+ {{i18n 'admin.customize.theme.import_web_tip'}}
+
+ {{/if}}
+
+{{/d-modal-body}}
+
+
diff --git a/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs b/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs
new file mode 100644
index 00000000000..3fbaf0ac865
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs
@@ -0,0 +1,8 @@
+
+ {{#d-modal-body title="admin.logs.staff_actions.modal_title"}}
+ {{{diff}}}
+ {{/d-modal-body}}
+
+
diff --git a/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs b/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs
deleted file mode 100644
index bbacea995c9..00000000000
--- a/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
- {{#d-modal-body title="admin.logs.staff_actions.modal_title"}}
-
- {{#if model.new_value}}
- {{site-customization-change-details change=model.new_value}}
- {{else}}
- {{i18n 'admin.logs.staff_actions.deleted'}}
- {{/if}}
-
-
- {{#if model.previous_value}}
- {{site-customization-change-details change=model.previous_value}}
- {{else}}
- {{i18n 'admin.logs.staff_actions.no_previous'}}
- {{/if}}
-
- {{/d-modal-body}}
-
-
diff --git a/app/assets/javascripts/discourse-common/components/combo-box.js.es6 b/app/assets/javascripts/discourse-common/components/combo-box.js.es6
index 2a2c4e2ba77..b2c66fc127f 100644
--- a/app/assets/javascripts/discourse-common/components/combo-box.js.es6
+++ b/app/assets/javascripts/discourse-common/components/combo-box.js.es6
@@ -22,17 +22,40 @@ export default Ember.Component.extend(bufferedRender({
let selected = this.get('value');
if (!Em.isNone(selected)) { selected = selected.toString(); }
- if (this.get('content')) {
- this.get('content').forEach(o => {
+ let selectedFound = false;
+ let firstVal = undefined;
+ const content = this.get('content');
+
+ if (content) {
+ let first = true;
+ content.forEach(o => {
let val = o[this.get('valueAttribute')];
if (typeof val === "undefined") { val = o; }
if (!Em.isNone(val)) { val = val.toString(); }
const selectedText = (val === selected) ? "selected" : "";
const name = Handlebars.Utils.escapeExpression(Ember.get(o, nameProperty) || o);
+
+ if (val === selected) {
+ selectedFound = true;
+ }
+ if (first) {
+ firstVal = val;
+ first = false;
+ }
buffer.push(`
`);
});
}
+
+ if (!selectedFound) {
+ if (none) {
+ this.set('value', null);
+ } else {
+ this.set('value', firstVal);
+ }
+ }
+
+ Ember.run.scheduleOnce('afterRender', this, this._updateSelect2);
},
@observes('value')
@@ -81,9 +104,14 @@ export default Ember.Component.extend(bufferedRender({
}
this.set('value', val);
});
+
Ember.run.scheduleOnce('afterRender', this, this._triggerChange);
},
+ _updateSelect2() {
+ this.$().trigger('change.select2');
+ },
+
_triggerChange() {
this.$().trigger('change');
},
diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6
index 72d05fb7dde..b56ac1628aa 100644
--- a/app/assets/javascripts/discourse/adapters/rest.js.es6
+++ b/app/assets/javascripts/discourse/adapters/rest.js.es6
@@ -1,7 +1,7 @@
import { ajax } from 'discourse/lib/ajax';
import { hashString } from 'discourse/lib/hash';
-const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host', 'web-hook', 'web-hook-event'];
+const ADMIN_MODELS = ['plugin', 'theme', 'embeddable-host', 'web-hook', 'web-hook-event'];
export function Result(payload, responseJson) {
this.payload = payload;
@@ -76,22 +76,38 @@ export default Ember.Object.extend({
this.cached[this.storageKey(type,findArgs,opts)] = hydrated;
},
+ jsonMode: false,
+
+ getPayload(method, data) {
+ let payload = {method, data};
+
+ if (this.jsonMode) {
+ payload.contentType = "application/json";
+ payload.data = JSON.stringify(data);
+ }
+
+ return payload;
+ },
+
update(store, type, id, attrs) {
const data = {};
const typeField = Ember.String.underscore(type);
data[typeField] = attrs;
- return ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) {
- return new Result(json[typeField], json);
- });
+
+ return ajax(this.pathFor(store, type, id), this.getPayload('PUT', data))
+ .then(function(json) {
+ return new Result(json[typeField], json);
+ });
},
createRecord(store, type, attrs) {
const data = {};
const typeField = Ember.String.underscore(type);
data[typeField] = attrs;
- return ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) {
- return new Result(json[typeField], json);
- });
+ return ajax(this.pathFor(store, type), this.getPayload('POST', data))
+ .then(function (json) {
+ return new Result(json[typeField], json);
+ });
},
destroyRecord(store, type, record) {
diff --git a/app/assets/javascripts/discourse/components/json-file-uploader.js.es6 b/app/assets/javascripts/discourse/components/json-file-uploader.js.es6
deleted file mode 100644
index e29491d48a1..00000000000
--- a/app/assets/javascripts/discourse/components/json-file-uploader.js.es6
+++ /dev/null
@@ -1,103 +0,0 @@
-
-export default Em.Component.extend({
- fileInput: null,
- loading: false,
- expectedRootObjectName: null,
- hover: 0,
-
- classNames: ['json-uploader'],
-
- _initialize: function() {
- const $this = this.$();
- const self = this;
-
- const $fileInput = $this.find('#js-file-input');
- this.set('fileInput', $fileInput[0]);
-
- $fileInput.on('change', function() {
- self.fileSelected(this.files);
- });
-
- $this.on('dragover', function(e) {
- if (e.preventDefault) e.preventDefault();
- return false;
- });
- $this.on('dragenter', function(e) {
- if (e.preventDefault) e.preventDefault();
- self.set('hover', self.get('hover') + 1);
- return false;
- });
- $this.on('dragleave', function(e) {
- if (e.preventDefault) e.preventDefault();
- self.set('hover', self.get('hover') - 1);
- return false;
- });
- $this.on('drop', function(e) {
- if (e.preventDefault) e.preventDefault();
-
- self.set('hover', 0);
- self.fileSelected(e.dataTransfer.files);
- return false;
- });
-
- }.on('didInsertElement'),
-
- accept: function() {
- return ".json,application/json,application/x-javascript,text/json" + (this.get('extension') ? "," + this.get('extension') : "");
- }.property('extension'),
-
- setReady: function() {
- let parsed;
- try {
- parsed = JSON.parse(this.get('value'));
- } catch (e) {
- this.set('ready', false);
- return;
- }
-
- const rootObject = parsed[this.get('expectedRootObjectName')];
-
- if (rootObject !== null && rootObject !== undefined) {
- this.set('ready', true);
- } else {
- this.set('ready', false);
- }
- }.observes('destination', 'expectedRootObjectName'),
-
- actions: {
- selectFile: function() {
- const $fileInput = $(this.get('fileInput'));
- $fileInput.click();
- }
- },
-
- fileSelected(fileList) {
- const self = this;
- let files = [];
- for (let i = 0; i < fileList.length; i++) {
- files[i] = fileList[i];
- }
- const fileNameRegex = /\.(json|txt)$/;
- files = files.filter(function(file) {
- if (fileNameRegex.test(file.name)) {
- return true;
- }
- if (file.type === "text/plain") {
- return true;
- }
- return false;
- });
- const firstFile = fileList[0];
-
- this.set('loading', true);
-
- let reader = new FileReader();
- reader.onload = function(evt) {
- self.set('value', evt.target.result);
- self.set('loading', false);
- };
-
- reader.readAsText(firstFile);
- }
-
-});
diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6
index dd6910d18a7..0bc886a8178 100644
--- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6
+++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6
@@ -143,12 +143,14 @@ export default Em.Component.extend({
matchRegEx = matchRegEx || replaceRegEx;
const match = this.filterBlocks(matchRegEx);
+ let val = this.get(key);
+
if (match.length !== 0) {
const userInput = match[0].replace(replaceRegEx, '');
- if (this.get(key) !== userInput) {
+ if (val !== userInput) {
this.set(key, userInput);
}
- } else if(this.get(key).length !== 0) {
+ } else if(val && val.length !== 0) {
this.set(key, '');
}
},
diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6
index 5ee945d4da2..9173a771539 100644
--- a/app/assets/javascripts/discourse/controllers/preferences.js.es6
+++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6
@@ -1,12 +1,23 @@
import { setting } from 'discourse/lib/computed';
import CanCheckEmails from 'discourse/mixins/can-check-emails';
import { popupAjaxError } from 'discourse/lib/ajax-error';
-import computed from "ember-addons/ember-computed-decorators";
+import { default as computed, observes } from "ember-addons/ember-computed-decorators";
import { cook } from 'discourse/lib/text';
import { NotificationLevels } from 'discourse/lib/notification-levels';
+import { listThemes, selectDefaultTheme, previewTheme } from 'discourse/lib/theme-selector';
export default Ember.Controller.extend(CanCheckEmails, {
+ userSelectableThemes: function(){
+ return listThemes(this.site);
+ }.property(),
+
+ @observes("selectedTheme")
+ themeKeyChanged() {
+ let key = this.get("selectedTheme");
+ previewTheme(key);
+ },
+
@computed("model.watchedCategories", "model.trackedCategories", "model.mutedCategories")
selectedCategories(watched, tracked, muted) {
return [].concat(watched, tracked, muted);
@@ -162,6 +173,7 @@ export default Ember.Controller.extend(CanCheckEmails, {
Discourse.User.currentProp('name', model.get('name'));
}
model.set('bio_cooked', cook(model.get('bio_raw')));
+ selectDefaultTheme(this.get('selectedTheme'));
this.set('saved', true);
}).catch(popupAjaxError);
},
diff --git a/app/assets/javascripts/discourse/controllers/upload-customization.js.es6 b/app/assets/javascripts/discourse/controllers/upload-customization.js.es6
deleted file mode 100644
index ae7575887a9..00000000000
--- a/app/assets/javascripts/discourse/controllers/upload-customization.js.es6
+++ /dev/null
@@ -1,30 +0,0 @@
-import ModalFunctionality from 'discourse/mixins/modal-functionality';
-
-export default Ember.Controller.extend(ModalFunctionality, {
- notReady: Em.computed.not('ready'),
- adminCustomizeCssHtml: Ember.inject.controller(),
-
- ready: function() {
- try {
- const parsed = JSON.parse(this.get('customizationFile'));
- return !!parsed["site_customization"];
- } catch (e) {
- return false;
- }
- }.property('customizationFile'),
-
- actions: {
- createCustomization: function() {
- const object = JSON.parse(this.get('customizationFile')).site_customization;
-
- // Slight fixup before creating object
- object.enabled = false;
- delete object.id;
- delete object.key;
-
- const controller = this.get('adminCustomizeCssHtml');
- controller.send('newCustomization', object);
- }
- }
-
-});
diff --git a/app/assets/javascripts/discourse/initializers/live-development.js.es6 b/app/assets/javascripts/discourse/initializers/live-development.js.es6
index f73172e154e..de6994b9324 100644
--- a/app/assets/javascripts/discourse/initializers/live-development.js.es6
+++ b/app/assets/javascripts/discourse/initializers/live-development.js.es6
@@ -1,5 +1,45 @@
import DiscourseURL from 'discourse/lib/url';
+export function refreshCSS(node, hash, newHref, options) {
+
+ let $orig = $(node);
+
+ if ($orig.data('reloading')) {
+
+ if (options && options.force) {
+ clearTimeout($orig.data('timeout'));
+ $orig.data("copy").remove();
+ } else {
+ return;
+ }
+ }
+
+ if (!$orig.data('orig')) {
+ $orig.data('orig', node.href);
+ }
+
+ $orig.data('reloading', true);
+
+ const orig = $(node).data('orig');
+
+ let reloaded = $orig.clone(true);
+ if (hash) {
+ reloaded[0].href = orig + (orig.indexOf('?') >= 0 ? "&hash=" : "?hash=") + hash;
+ } else {
+ reloaded[0].href = newHref;
+ }
+
+ $orig.after(reloaded);
+
+ let timeout = setTimeout(()=>{
+ $orig.remove();
+ reloaded.data('reloading', false);
+ }, 2000);
+
+ $orig.data("timeout", timeout);
+ $orig.data("copy", reloaded);
+}
+
// Use the message bus for live reloading of components for faster development.
export default {
name: "live-development",
@@ -48,17 +88,8 @@ export default {
document.location.reload(true);
} else {
$('link').each(function() {
- // TODO: stop bundling css in DEV please
- if (true || (this.href.match(me.name) && me.hash)) {
- if (!$(this).data('orig')) {
- $(this).data('orig', this.href);
- }
- const orig = $(this).data('orig');
- if (!me.hash) {
- window.__uniq = window.__uniq || 1;
- me.hash = window.__uniq++;
- }
- this.href = orig + (orig.indexOf('?') >= 0 ? "&hash=" : "?hash=") + me.hash;
+ if (this.href.match(me.name) && (me.hash || me.new_href)) {
+ refreshCSS(this, me.hash, me.new_href);
}
});
}
diff --git a/app/assets/javascripts/discourse/lib/load-script.js.es6 b/app/assets/javascripts/discourse/lib/load-script.js.es6
index cc24df86de8..13dbbb6ecf4 100644
--- a/app/assets/javascripts/discourse/lib/load-script.js.es6
+++ b/app/assets/javascripts/discourse/lib/load-script.js.es6
@@ -24,6 +24,10 @@ function loadWithTag(path, cb) {
};
}
+export function loadCSS(url) {
+ return loadScript(url, { css: true });
+}
+
export default function loadScript(url, opts) {
// TODO: Remove this once plugins have been updated not to use it:
@@ -47,8 +51,11 @@ export default function loadScript(url, opts) {
delete _loading[url];
});
- const cb = function() {
+ const cb = function(data) {
_loaded[url] = true;
+ if (opts && opts.css) {
+ $("head").append("");
+ }
done();
resolve();
};
@@ -66,7 +73,7 @@ export default function loadScript(url, opts) {
if (opts.scriptTag) {
loadWithTag(cdnUrl, cb);
} else {
- ajax({url: cdnUrl, dataType: "script", cache: true}).then(cb);
+ ajax({url: cdnUrl, dataType: opts.css ? "text": "script", cache: true}).then(cb);
}
});
}
diff --git a/app/assets/javascripts/discourse/lib/theme-selector.js.es6 b/app/assets/javascripts/discourse/lib/theme-selector.js.es6
new file mode 100644
index 00000000000..de7a81daf93
--- /dev/null
+++ b/app/assets/javascripts/discourse/lib/theme-selector.js.es6
@@ -0,0 +1,62 @@
+import { ajax } from 'discourse/lib/ajax';
+import { refreshCSS } from 'discourse/initializers/live-development';
+const keySelector = 'meta[name=discourse_theme_key]';
+
+function currentThemeKey() {
+ let themeKey = null;
+ let elem = _.first($(keySelector));
+ if (elem) {
+ themeKey = elem.content;
+ }
+ return themeKey;
+}
+
+export function selectDefaultTheme(key) {
+ if (key) {
+ $.cookie('preview_style', key);
+ } else {
+ $.cookie('preview_style', null);
+ }
+}
+
+export function previewTheme(key) {
+ if (currentThemeKey() !== key) {
+
+ Discourse.set("assetVersion", "forceRefresh");
+
+ ajax(`/themes/assets/${key ? key : 'default'}`).then(results => {
+ let elem = _.first($(keySelector));
+ if (elem) {
+ elem.content = key;
+ }
+
+ results.themes.forEach(theme => {
+ let node = $(`link[rel=stylesheet][data-target=${theme.target}]`)[0];
+ if (node) {
+ refreshCSS(node, null, theme.url, {force: true});
+ }
+ });
+ });
+ }
+}
+
+export function listThemes(site) {
+ let themes = site.get('user_themes');
+
+ if (!themes) {
+ return null;
+ }
+
+ let hasDefault = !!themes.findBy('default', true);
+
+ let results = [];
+ if (!hasDefault) {
+ results.push({name: I18n.t('themes.default_description'), id: null});
+ }
+
+ themes.forEach(t=>{
+ results.push({name: t.name, id: t.theme_key});
+ });
+
+ return results.length === 0 ? null : results;
+}
diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6
index 753f2dd5abf..0d1ef7cdac0 100644
--- a/app/assets/javascripts/discourse/models/store.js.es6
+++ b/app/assets/javascripts/discourse/models/store.js.es6
@@ -56,9 +56,13 @@ export default Ember.Object.extend({
},
findAll(type, findArgs) {
- const self = this;
- return this.adapterFor(type).findAll(this, type, findArgs).then(function(result) {
- return self._resultSet(type, result);
+ const adapter = this.adapterFor(type);
+ return adapter.findAll(this, type, findArgs).then((result) => {
+ let results = this._resultSet(type, result);
+ if (adapter.afterFindAll) {
+ results = adapter.afterFindAll(results);
+ }
+ return results;
});
},
diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index b26648042c0..6de4f047671 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -11,7 +11,8 @@ export default RestrictedUserRoute.extend({
controller.reset();
controller.setProperties({
model: user,
- newNameInput: user.get('name')
+ newNameInput: user.get('name'),
+ selectedTheme: $.cookie('preview_style')
});
},
diff --git a/app/assets/javascripts/discourse/templates/components/color-input.hbs b/app/assets/javascripts/discourse/templates/components/color-input.hbs
index e69d19d7e10..1b9f36ecb39 100644
--- a/app/assets/javascripts/discourse/templates/components/color-input.hbs
+++ b/app/assets/javascripts/discourse/templates/components/color-input.hbs
@@ -1 +1 @@
-{{text-field class="hex-input" value=hexValue maxlength="6"}}
+{{text-field class="hex-input" value=hexValue maxlength="6"}}
diff --git a/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs b/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs
deleted file mode 100644
index b34f6c4b9f4..00000000000
--- a/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
- {{d-button class="fileSelect" action="selectFile" class="" icon="upload" label="upload_selector.select_file"}}
- {{conditional-loading-spinner condition=loading size="small"}}
-
-
{{i18n "alternation"}}
-
- {{textarea value=value}}
-
-
{{fa-icon "upload"}}
-
diff --git a/app/assets/javascripts/discourse/templates/preferences.hbs b/app/assets/javascripts/discourse/templates/preferences.hbs
index 6fea4f7f6f7..3a0ec94d69b 100644
--- a/app/assets/javascripts/discourse/templates/preferences.hbs
+++ b/app/assets/javascripts/discourse/templates/preferences.hbs
@@ -354,6 +354,15 @@
{{/if}}
+ {{#if userSelectableThemes}}
+
+
+
+ {{combo-box content=userSelectableThemes value=selectedTheme}}
+
+
+ {{/if}}
+
{{plugin-outlet name="user-custom-controls" args=(hash model=model)}}
diff --git a/app/assets/javascripts/wizard/components/theme-preview.js.es6 b/app/assets/javascripts/wizard/components/theme-preview.js.es6
index 19a9863b155..d4ec95b2171 100644
--- a/app/assets/javascripts/wizard/components/theme-preview.js.es6
+++ b/app/assets/javascripts/wizard/components/theme-preview.js.es6
@@ -11,7 +11,7 @@ export default createPreviewComponent(659, 320, {
logo: null,
avatar: null,
- @observes('step.fieldsById.theme_id.value')
+ @observes('step.fieldsById.base_scheme_id.value')
themeChanged() {
this.triggerRepaint();
},
diff --git a/app/assets/javascripts/wizard/models/wizard.js.es6 b/app/assets/javascripts/wizard/models/wizard.js.es6
index d98ba5e51ee..8960354e584 100644
--- a/app/assets/javascripts/wizard/models/wizard.js.es6
+++ b/app/assets/javascripts/wizard/models/wizard.js.es6
@@ -25,7 +25,7 @@ const Wizard = Ember.Object.extend({
const colorStep = this.get('steps').findBy('id', 'colors');
if (!colorStep) { return; }
- const themeChoice = colorStep.get('fieldsById.theme_id');
+ const themeChoice = colorStep.get('fieldsById.base_scheme_id');
if (!themeChoice) { return; }
const themeId = themeChoice.get('value');
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 0fba7c199bc..fc3044045f4 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -3,6 +3,8 @@
@import "common/foundation/mixins";
@import "common/foundation/helpers";
+@import "common/admin/customize";
+
$mobile-breakpoint: 700px;
// Change the box model for .admin-content
@@ -724,138 +726,6 @@ section.details {
}
}
-// Customise area
-.customize {
- .admin-footer {
- margin-top: 20px;
- }
- .current-style.maximized {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 100000;
- background-color: white;
- width: 100%;
- padding: 0;
- margin: 0;
- .wrapper {
- position: absolute;
- top: 20px;
- bottom: 10px;
- left: 20px;
- right: 20px;
- }
- }
- .nav.nav-pills {
- margin-left: 10px;
- }
- .content-list, .current-style {
- float: left;
- }
- .content-list ul {
- margin-bottom: 10px;
- }
- .current-style {
- .nav.nav-pills{
- position: relative;
- }
- .toggle-mobile {
- position: absolute;
- right: 35px;
- font-size: 20px;
- }
- .toggle-maximize {
- position: absolute;
- right: -5px;
- }
- .delete-link {
- margin-left: 15px;
- margin-top: 5px;
- }
- .preview-link {
- margin-left: 15px;
- }
- .export {
- float: right;
- }
- padding-left: 10px;
- width: 70%;
- .style-name {
- width: 350px;
- height: 25px;
- // Remove height to for `box-sizing: border-box`
- height: auto;
- }
- .ace-wrapper {
- position: relative;
- height: 400px;
- width: 100%;
- }
- &.maximized {
- .admin-container {
- position: absolute;
- bottom: 50px;
- top: 80px;
- width: 100%;
- }
- .admin-footer {
- position: absolute;
- bottom: 10px;
- }
- .ace-wrapper {
- height: 100%;
- }
- }
- .ace_editor {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- }
- .status-actions {
- float: right;
- margin-top: 7px;
- span {
- margin-right: 10px;
- }
- }
- .buttons {
- float: left;
- width: 200px;
- .saving {
- padding: 5px 0 0 0;
- margin-left: 10px;
- width: 80px;
- color: $primary;
- }
- }
- }
- .color-scheme {
- .controls {
- span, button, a {
- margin-right: 10px;
- }
- }
- }
- .colors {
- thead th { border: none; }
- td.hex { width: 100px; }
- td.actions { width: 200px; }
- .hex-input { width: 80px; margin-bottom: 0; }
- .hex { text-align: center; }
- .description { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); }
-
- .invalid .hex input {
- background-color: white;
- color: black;
- border-color: $danger;
- }
- }
-}
-
.admin-flags {
.hidden-post td.excerpt,
.hidden-post td.user {
@@ -1970,6 +1840,12 @@ table#user-badges {
background: $secondary;
}
}
+
+.inline-edit label {
+ display: inline-block;
+ margin-right: 20px;
+}
+
.cbox0 { background: blend-primary-secondary(0%); }
.cbox10 { background: blend-primary-secondary(10%); }
.cbox20 { background: blend-primary-secondary(20%); }
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
new file mode 100644
index 00000000000..6b726ec61fd
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -0,0 +1,157 @@
+// Customise area
+.customize {
+ .admin-container {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+ .admin-footer {
+ margin-top: 20px;
+ }
+ .show-current-style {
+ margin-left: 20px;
+ float: left;
+ width: 70%;
+ h2 {
+ margin-bottom: 15px;
+ }
+ h3 {
+ margin-bottom: 10px;
+ margin-top: 30px;
+ }
+ }
+
+ .current-style.maximized {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 100000;
+ background-color: white;
+ width: 100%;
+ padding: 0;
+ margin: 0;
+ .wrapper {
+ position: absolute;
+ top: 20px;
+ bottom: 10px;
+ left: 20px;
+ right: 20px;
+ }
+ }
+
+ .nav.nav-pills.fields {
+ margin-left: 10px;
+ }
+ .content-list, .current-style {
+ float: left;
+ }
+ .content-list ul {
+ margin-bottom: 10px;
+ }
+ .current-style {
+ width: 100%;
+
+ .admin-container {
+ margin: 0;
+ }
+
+ .nav.target {
+ margin-top: 15px;
+ .fa {
+ margin-left: 3px;
+ }
+ .fa-mobile {
+ font-size: 1.3em;
+ }
+ }
+
+ .toggle-maximize {
+ position: absolute;
+ right: -5px;
+ }
+
+ .ace-wrapper {
+ position: relative;
+ height: 600px;
+ width: 100%;
+ }
+
+ &.maximized {
+ .admin-container {
+ position: absolute;
+ bottom: 50px;
+ top: 80px;
+ width: 100%;
+ }
+ .admin-footer {
+ position: absolute;
+ bottom: 10px;
+ }
+ .ace-wrapper {
+ height: 100%;
+ }
+ }
+
+ .custom-ace-gutter {
+ width: 41px;
+ background-color: #ebebeb;
+ height: 15px;
+ }
+
+ .ace_editor {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ }
+
+ .status-actions {
+ float: right;
+ margin-top: 7px;
+ span {
+ margin-right: 10px;
+ }
+ }
+
+ .buttons {
+ float: left;
+ width: 200px;
+ .saving {
+ padding: 5px 0 0 0;
+ margin-left: 10px;
+ width: 80px;
+ color: $primary;
+ }
+ }
+ }
+ .color-scheme {
+ .controls {
+ span, button, a {
+ margin-right: 10px;
+ }
+ }
+ }
+ .colors {
+ thead th { border: none; }
+ td.hex { width: 160px; }
+ td.actions { width: 200px; }
+ .hex-input { width: 80px; margin-bottom: 0; }
+ .hex { text-align: center; }
+ .description { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); }
+
+ .invalid .hex input {
+ background-color: white;
+ color: black;
+ border-color: $danger;
+ }
+ }
+
+ .status-message {
+ display: block;
+ font-size: 0.8em;
+ margin-top: 8px;
+ }
+}
+
diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/badges.css.scss
rename to app/assets/stylesheets/common/components/badges.scss
diff --git a/app/assets/stylesheets/common/components/banner.css.scss b/app/assets/stylesheets/common/components/banner.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/banner.css.scss
rename to app/assets/stylesheets/common/components/banner.scss
diff --git a/app/assets/stylesheets/common/components/buttons.css.scss b/app/assets/stylesheets/common/components/buttons.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/buttons.css.scss
rename to app/assets/stylesheets/common/components/buttons.scss
diff --git a/app/assets/stylesheets/common/components/date-picker.css.scss b/app/assets/stylesheets/common/components/date-picker.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/date-picker.css.scss
rename to app/assets/stylesheets/common/components/date-picker.scss
diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss
rename to app/assets/stylesheets/common/components/keyboard_shortcuts.scss
diff --git a/app/assets/stylesheets/common/components/navs.css.scss b/app/assets/stylesheets/common/components/navs.scss
similarity index 100%
rename from app/assets/stylesheets/common/components/navs.css.scss
rename to app/assets/stylesheets/common/components/navs.scss
diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss
index 2a3c5588d02..35b0418187d 100644
--- a/app/assets/stylesheets/common/foundation/base.scss
+++ b/app/assets/stylesheets/common/foundation/base.scss
@@ -1,5 +1,5 @@
-@import "common/foundation/variables";
-@import "common/foundation/mixins";
+@import "./variables";
+@import "./mixins";
// --------------------------------------------------
// Base styles for HTML elements
diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss
index b06099e3b5f..43eb44cdb2d 100644
--- a/app/assets/stylesheets/common/foundation/variables.scss
+++ b/app/assets/stylesheets/common/foundation/variables.scss
@@ -28,7 +28,7 @@ $base-font-size: 14px !default;
$base-line-height: 19px !default;
$base-font-family: Helvetica, Arial, sans-serif !default;
-/* These files don't actually exist. They're injected by DiscourseSassImporter. */
+/* These files don't actually exist. They're injected by Stylesheet::Compiler. */
@import "theme_variables";
@import "plugins_variables";
@import "common/foundation/math";
diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss
index 807b43c7260..71f19278eda 100644
--- a/app/assets/stylesheets/desktop.scss
+++ b/app/assets/stylesheets/desktop.scss
@@ -21,7 +21,7 @@
@import "desktop/menu-panel";
@import "desktop/group";
-/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */
+/* These files doesn't actually exist, they are injected by Stylesheet::Compiler. */
@import "plugins";
@import "plugins_desktop";
diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.scss
similarity index 97%
rename from app/assets/stylesheets/embed.css.scss
rename to app/assets/stylesheets/embed.scss
index afb4a40c403..66af74d3d0b 100644
--- a/app/assets/stylesheets/embed.css.scss
+++ b/app/assets/stylesheets/embed.scss
@@ -1,6 +1,5 @@
-//= require ./vendor/normalize
-//= require ./common/foundation/base
-
+@import "./vendor/normalize";
+@import "./common/foundation/base";
@import "./common/foundation/variables";
@import "./common/foundation/colors";
@import "./common/foundation/mixins";
diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss
index ce220071ee6..b1592b6d6ab 100644
--- a/app/assets/stylesheets/mobile.scss
+++ b/app/assets/stylesheets/mobile.scss
@@ -24,7 +24,7 @@
@import "mobile/ring";
@import "mobile/group";
-/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */
+/* These files doesn't actually exist, they are injected by Stylesheet::Compiler. */
@import "plugins";
@import "plugins_mobile";
diff --git a/app/assets/stylesheets/vendor/sweetalert.css b/app/assets/stylesheets/vendor/sweetalert.scss
old mode 100755
new mode 100644
similarity index 100%
rename from app/assets/stylesheets/vendor/sweetalert.css
rename to app/assets/stylesheets/vendor/sweetalert.scss
diff --git a/app/controllers/admin/color_schemes_controller.rb b/app/controllers/admin/color_schemes_controller.rb
index 35f45c6e4ad..dda6c9f07ce 100644
--- a/app/controllers/admin/color_schemes_controller.rb
+++ b/app/controllers/admin/color_schemes_controller.rb
@@ -3,7 +3,7 @@ class Admin::ColorSchemesController < Admin::AdminController
before_filter :fetch_color_scheme, only: [:update, :destroy]
def index
- render_serialized([ColorScheme.base] + ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer)
+ render_serialized(ColorScheme.base_color_schemes + ColorScheme.order('id ASC').all.to_a, ColorSchemeSerializer)
end
def create
@@ -37,6 +37,6 @@ class Admin::ColorSchemesController < Admin::AdminController
end
def color_scheme_params
- params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex]])[:color_scheme]
+ params.permit(color_scheme: [:base_scheme_id, :name, colors: [:name, :hex]])[:color_scheme]
end
end
diff --git a/app/controllers/admin/site_customizations_controller.rb b/app/controllers/admin/site_customizations_controller.rb
deleted file mode 100644
index afd3c162e57..00000000000
--- a/app/controllers/admin/site_customizations_controller.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-class Admin::SiteCustomizationsController < Admin::AdminController
-
- before_filter :enable_customization
-
- skip_before_filter :check_xhr, only: [:show]
-
- def index
- @site_customizations = SiteCustomization.order(:name)
-
- respond_to do |format|
- format.json { render json: @site_customizations }
- end
- end
-
- def create
- @site_customization = SiteCustomization.new(site_customization_params)
- @site_customization.user_id = current_user.id
-
- respond_to do |format|
- if @site_customization.save
- log_site_customization_change(nil, site_customization_params)
- format.json { render json: @site_customization, status: :created}
- else
- format.json { render json: @site_customization.errors, status: :unprocessable_entity }
- end
- end
- end
-
- def update
- @site_customization = SiteCustomization.find(params[:id])
- log_record = log_site_customization_change(@site_customization, site_customization_params)
-
- respond_to do |format|
- if @site_customization.update_attributes(site_customization_params)
- format.json { render json: @site_customization, status: :created}
- else
- log_record.destroy if log_record
- format.json { render json: @site_customization.errors, status: :unprocessable_entity }
- end
- end
- end
-
- def destroy
- @site_customization = SiteCustomization.find(params[:id])
- StaffActionLogger.new(current_user).log_site_customization_destroy(@site_customization)
- @site_customization.destroy
-
- respond_to do |format|
- format.json { head :no_content }
- end
- end
-
- def show
- @site_customization = SiteCustomization.find(params[:id])
-
- respond_to do |format|
- format.json do
- check_xhr
- render json: SiteCustomizationSerializer.new(@site_customization)
- end
-
- format.any(:html, :text) do
- raise RenderEmpty.new if request.xhr?
-
- response.headers['Content-Disposition'] = "attachment; filename=#{@site_customization.name.parameterize}.dcstyle.json"
- response.sending_file = true
- render json: SiteCustomizationSerializer.new(@site_customization)
- end
- end
-
- end
-
- private
-
- def site_customization_params
- params.require(:site_customization)
- .permit(:name, :stylesheet, :header, :top, :footer,
- :mobile_stylesheet, :mobile_header, :mobile_top, :mobile_footer,
- :head_tag, :body_tag,
- :position, :enabled, :key,
- :stylesheet_baked, :embedded_css)
- end
-
- def log_site_customization_change(old_record, new_params)
- StaffActionLogger.new(current_user).log_site_customization_change(old_record, new_params)
- end
-
- def enable_customization
- session[:disable_customization] = false
- end
-
-end
diff --git a/app/controllers/admin/staff_action_logs_controller.rb b/app/controllers/admin/staff_action_logs_controller.rb
index 5324aabc0dd..b004601014f 100644
--- a/app/controllers/admin/staff_action_logs_controller.rb
+++ b/app/controllers/admin/staff_action_logs_controller.rb
@@ -6,4 +6,73 @@ class Admin::StaffActionLogsController < Admin::AdminController
render_serialized(staff_action_logs, UserHistorySerializer)
end
+ def diff
+ require_dependency "discourse_diff"
+
+ @history = UserHistory.find(params[:id])
+ prev = @history.previous_value
+ cur = @history.new_value
+
+ prev = JSON.parse(prev) if prev
+ cur = JSON.parse(cur) if cur
+
+ diff_fields = {}
+
+ output = "
#{CGI.escapeHTML(cur["name"].to_s)}
"
+
+ diff_fields["name"] = {
+ prev: prev["name"].to_s,
+ cur: cur["name"].to_s,
+ }
+
+ ["default", "user_selectable"].each do |f|
+ diff_fields[f] = {
+ prev: (!!prev[f]).to_s,
+ cur: (!!cur[f]).to_s
+ }
+ end
+
+ diff_fields["color scheme"] = {
+ prev: prev["color_scheme"]&.fetch("name").to_s,
+ cur: cur["color_scheme"]&.fetch("name").to_s,
+ }
+
+ diff_fields["included themes"] = {
+ prev: child_themes(prev),
+ cur: child_themes(cur)
+ }
+
+
+ load_diff(diff_fields, :cur, cur)
+ load_diff(diff_fields, :prev, prev)
+
+ diff_fields.delete_if{|k,v| v[:cur] == v[:prev]}
+
+
+ diff_fields.each do |k,v|
+ output << "
#{k}
"
+ diff = DiscourseDiff.new(v[:prev] || "", v[:cur] || "")
+ output << diff.side_by_side_markdown
+ end
+
+ render json: {side_by_side: output}
+ end
+
+ protected
+
+ def child_themes(theme)
+ return "" unless children = theme["child_themes"]
+
+ children.map{|row| row["name"]}.join(" ").to_s
+ end
+
+ def load_diff(hash, key, val)
+ if f=val["theme_fields"]
+ f.each do |row|
+ entry = hash[row["target"] + " " + row["name"]] ||= {}
+ entry[key] = row["value"]
+ end
+ end
+ end
+
end
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb
new file mode 100644
index 00000000000..083cc61359f
--- /dev/null
+++ b/app/controllers/admin/themes_controller.rb
@@ -0,0 +1,192 @@
+class Admin::ThemesController < Admin::AdminController
+
+ skip_before_filter :check_xhr, only: [:show]
+
+ def import
+
+ @theme = nil
+ if params[:theme]
+ json = JSON::parse(params[:theme].read)
+ theme = json['theme']
+
+ @theme = Theme.new(name: theme["name"], user_id: current_user.id)
+ theme["theme_fields"]&.each do |field|
+ @theme.set_field(field["target"], field["name"], field["value"])
+ end
+
+ if @theme.save
+ log_theme_change(nil, @theme)
+ render json: @theme, status: :created
+ else
+ render json: @theme.errors, status: :unprocessable_entity
+ end
+ elsif params[:remote]
+ @theme = RemoteTheme.import_theme(params[:remote])
+ render json: @theme, status: :created
+ else
+ render json: @theme.errors, status: :unprocessable_entity
+ end
+
+ end
+
+ def index
+ @theme = Theme.order(:name).includes(:theme_fields, :remote_theme)
+ @color_schemes = ColorScheme.all.to_a
+ light = ColorScheme.new(name: I18n.t("color_schemes.default"))
+ @color_schemes.unshift(light)
+
+ payload = {
+ themes: ActiveModel::ArraySerializer.new(@theme, each_serializer: ThemeSerializer),
+ extras: {
+ color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer)
+ }
+ }
+
+ respond_to do |format|
+ format.json { render json: payload}
+ end
+ end
+
+ def create
+ @theme = Theme.new(name: theme_params[:name],
+ user_id: current_user.id,
+ user_selectable: theme_params[:user_selectable] || false,
+ color_scheme_id: theme_params[:color_scheme_id])
+ set_fields
+
+ respond_to do |format|
+ if @theme.save
+ update_default_theme
+ log_theme_change(nil, @theme)
+ format.json { render json: @theme, status: :created}
+ else
+ format.json { render json: @theme.errors, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ def update
+ @theme = Theme.find(params[:id])
+
+ original_json = ThemeSerializer.new(@theme, root: false).to_json
+
+ [:name, :color_scheme_id, :user_selectable].each do |field|
+ if theme_params.key?(field)
+ @theme.send("#{field}=", theme_params[field])
+ end
+ end
+
+ if theme_params.key?(:child_theme_ids)
+ expected = theme_params[:child_theme_ids].map(&:to_i)
+
+ @theme.child_theme_relation.to_a.each do |child|
+ if expected.include?(child.child_theme_id)
+ expected.reject!{|id| id == child.child_theme_id}
+ else
+ child.destroy
+ end
+ end
+
+ Theme.where(id: expected).each do |theme|
+ @theme.add_child_theme!(theme)
+ end
+
+ end
+
+ set_fields
+
+ if params[:theme][:remote_check]
+ @theme.remote_theme.update_remote_version
+ @theme.remote_theme.save!
+ end
+
+ if params[:theme][:remote_update]
+ @theme.remote_theme.update_from_remote
+ @theme.remote_theme.save!
+ end
+
+ respond_to do |format|
+ if @theme.save
+
+ update_default_theme
+
+ log_theme_change(original_json, @theme)
+ format.json { render json: @theme, status: :created}
+ else
+ format.json { render json: @theme.errors, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ def destroy
+ @theme = Theme.find(params[:id])
+ StaffActionLogger.new(current_user).log_theme_destroy(@theme)
+ @theme.destroy
+
+ respond_to do |format|
+ format.json { head :no_content }
+ end
+ end
+
+ def show
+ @theme = Theme.find(params[:id])
+
+ respond_to do |format|
+ format.json do
+ check_xhr
+ render json: ThemeSerializer.new(@theme)
+ end
+
+ format.any(:html, :text) do
+ raise RenderEmpty.new if request.xhr?
+
+ response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json"
+ response.sending_file = true
+ render json: ThemeSerializer.new(@theme)
+ end
+ end
+
+ end
+
+ private
+
+ def update_default_theme
+ if theme_params.key?(:default)
+ is_default = theme_params[:default]
+ if @theme.key == SiteSetting.default_theme_key && !is_default
+ Theme.clear_default!
+ elsif is_default
+ @theme.set_default!
+ end
+ end
+ end
+
+ def theme_params
+ @theme_params ||=
+ begin
+ # deep munge is a train wreck, work around it for now
+ params[:theme][:child_theme_ids] ||= [] if params[:theme].key?(:child_theme_ids)
+ params.require(:theme)
+ .permit(:name,
+ :color_scheme_id,
+ :default,
+ :user_selectable,
+ theme_fields: [:name, :target, :value],
+ child_theme_ids: [])
+ end
+ end
+
+ def set_fields
+
+ return unless fields = theme_params[:theme_fields]
+
+ fields.each do |field|
+ @theme.set_field(field[:target], field[:name], field[:value])
+ end
+ end
+
+ def log_theme_change(old_record, new_record)
+ StaffActionLogger.new(current_user).log_theme_change(old_record, new_record)
+ end
+
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d7a37a3a456..f0856406b4f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -411,8 +411,8 @@ class ApplicationController < ActionController::Base
def custom_html_json
target = view_context.mobile_view? ? :mobile : :desktop
data = {
- top: SiteCustomization.custom_top(session[:preview_style], target),
- footer: SiteCustomization.custom_footer(session[:preview_style], target)
+ top: Theme.lookup_field(session[:preview_style], target, "after_header"),
+ footer: Theme.lookup_field(session[:preview_style], target, "footer")
}
if DiscoursePluginRegistry.custom_html
diff --git a/app/controllers/site_customizations_controller.rb b/app/controllers/site_customizations_controller.rb
deleted file mode 100644
index 34a314720fe..00000000000
--- a/app/controllers/site_customizations_controller.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-class SiteCustomizationsController < ApplicationController
- skip_before_filter :preload_json, :check_xhr, :redirect_to_login_if_required
-
- def show
- no_cookies
-
- cache_time = request.env["HTTP_IF_MODIFIED_SINCE"]
- cache_time = Time.rfc2822(cache_time) rescue nil if cache_time
- stylesheet_time =
- begin
- if params[:key].to_s == SiteCustomization::ENABLED_KEY
- SiteCustomization.where(enabled: true)
- .order('created_at desc')
- .limit(1)
- .pluck(:created_at)
- .first
- else
- SiteCustomization.where(key: params[:key].to_s).pluck(:created_at).first
- end
- end
-
- if !stylesheet_time
- raise Discourse::NotFound
- end
-
- if cache_time && stylesheet_time <= cache_time
- return render nothing: true, status: 304
- end
-
- response.headers["Last-Modified"] = stylesheet_time.httpdate
- expires_in 1.year, public: true
- render text: SiteCustomization.stylesheet_contents(params[:key], params[:target]),
- content_type: "text/css"
- end
-end
diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb
index 55c5166d4f1..dea3d5d34c5 100644
--- a/app/controllers/stylesheets_controller.rb
+++ b/app/controllers/stylesheets_controller.rb
@@ -1,12 +1,40 @@
class StylesheetsController < ApplicationController
- skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show]
+ skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_source_map]
+
+ def show_source_map
+ show_resource(source_map: true)
+ end
def show
+ show_resource
+ end
+
+ protected
+
+ def show_resource(source_map: false)
+
+ extension = source_map ? ".css.map" : ".css"
+
+ params[:name]
no_cookies
target,digest = params[:name].split(/_([a-f0-9]{40})/)
+ if Rails.env == "development"
+ # TODO add theme
+ # calling this method ensures we have a cache for said target
+ # we hold of re-compilation till someone asks for asset
+ if target.include?("theme")
+ split_target,theme_id = target.split(/_(-?[0-9]+)/)
+ theme = Theme.find(theme_id) if theme_id
+ else
+ split_target,color_scheme_id = target.split(/_(-?[0-9]+)/)
+ theme = Theme.find_by(color_scheme_id: color_scheme_id)
+ end
+ Stylesheet::Manager.stylesheet_link_tag(split_target, nil, theme&.key)
+ end
+
cache_time = request.env["HTTP_IF_MODIFIED_SINCE"]
cache_time = Time.rfc2822(cache_time) rescue nil if cache_time
@@ -19,7 +47,7 @@ class StylesheetsController < ApplicationController
# Security note, safe due to route constraint
underscore_digest = digest ? "_" + digest : ""
- location = "#{Rails.root}/#{DiscourseStylesheets::CACHE_PATH}/#{target}#{underscore_digest}.css"
+ location = "#{Rails.root}/#{Stylesheet::Manager::CACHE_PATH}/#{target}#{underscore_digest}#{extension}"
stylesheet_time = query.pluck(:created_at).first
@@ -33,24 +61,31 @@ class StylesheetsController < ApplicationController
unless File.exist?(location)
- if current = query.first
- File.write(location, current.content)
+ if current = query.limit(1).pluck(source_map ? :source_map : :content).first
+ File.write(location, current)
else
raise Discourse::NotFound
end
end
- response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time
- immutable_for(1.year) unless Rails.env == "development"
+ if Rails.env == "development"
+ response.headers['Last-Modified'] = Time.zone.now.httpdate
+ immutable_for(1.second)
+ else
+ response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time
+ immutable_for(1.year)
+ end
send_file(location, disposition: :inline)
end
- protected
-
def handle_missing_cache(location, name, digest)
+ location = location.sub(".css.map", ".css")
+ source_map_location = location + ".map"
+
existing = File.read(location) rescue nil
if existing && digest
- StylesheetCache.add(name, digest, existing)
+ source_map = File.read(source_map_location) rescue nil
+ StylesheetCache.add(name, digest, existing, source_map)
end
end
diff --git a/app/controllers/themes_controller.rb b/app/controllers/themes_controller.rb
new file mode 100644
index 00000000000..a5f26c5cc4d
--- /dev/null
+++ b/app/controllers/themes_controller.rb
@@ -0,0 +1,28 @@
+class ThemesController < ::ApplicationController
+ def assets
+ theme_key = params[:key].to_s
+
+ if theme_key == "default"
+ theme_key = nil
+ else
+ raise Discourse::NotFound unless Theme.where(key: theme_key).exists?
+ end
+
+ object = [:mobile, :desktop, :desktop_theme, :mobile_theme].map do |target|
+ link = Stylesheet::Manager.stylesheet_link_tag(target, 'all', params[:key])
+ if link
+ href = link.split(/["']/)[1]
+ if Rails.env.development?
+ href << (href.include?("?") ? "&" : "?")
+ href << SecureRandom.hex
+ end
+ {
+ target: target,
+ url: href
+ }
+ end
+ end.compact
+
+ render json: object.as_json
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index a9e4ed40e8e..74ad085e700 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -316,4 +316,27 @@ module ApplicationHelper
''
end
end
+
+ def theme_key
+ if customization_disabled?
+ nil
+ else
+ session[:preview_style] || SiteSetting.default_theme_key
+ end
+ end
+
+ def theme_lookup(name)
+ lookup = Theme.lookup_field(theme_key, mobile_view? ? :mobile : :desktop, name)
+ lookup.html_safe if lookup
+ end
+
+ def discourse_stylesheet_link_tag(name, opts={})
+ if opts.key?(:theme_key)
+ key = opts[:theme_key] unless customization_disabled?
+ else
+ key = theme_key
+ end
+
+ Stylesheet::Manager.stylesheet_link_tag(name, 'all', key)
+ end
end
diff --git a/app/models/category.rb b/app/models/category.rb
index 2a48c6a53af..a533a3863fa 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -1,5 +1,4 @@
require_dependency 'distributed_cache'
-require_dependency 'sass/discourse_stylesheets'
class Category < ActiveRecord::Base
@@ -492,7 +491,7 @@ SQL
end
def publish_discourse_stylesheet
- DiscourseStylesheets.cache.clear
+ Stylesheet::Manager.cache.clear
end
def index_search
diff --git a/app/models/child_theme.rb b/app/models/child_theme.rb
new file mode 100644
index 00000000000..6e101bd8aae
--- /dev/null
+++ b/app/models/child_theme.rb
@@ -0,0 +1,20 @@
+class ChildTheme < ActiveRecord::Base
+ belongs_to :parent_theme, class_name: 'Theme'
+ belongs_to :child_theme, class_name: 'Theme'
+end
+
+# == Schema Information
+#
+# Table name: child_themes
+#
+# id :integer not null, primary key
+# parent_theme_id :integer
+# child_theme_id :integer
+# created_at :datetime
+# updated_at :datetime
+#
+# Indexes
+#
+# index_child_themes_on_child_theme_id_and_parent_theme_id (child_theme_id,parent_theme_id) UNIQUE
+# index_child_themes_on_parent_theme_id_and_child_theme_id (parent_theme_id,child_theme_id) UNIQUE
+#
diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb
index d9c9216c277..f2b056faea5 100644
--- a/app/models/color_scheme.rb
+++ b/app/models/color_scheme.rb
@@ -1,32 +1,36 @@
-require_dependency 'sass/discourse_stylesheets'
require_dependency 'distributed_cache'
class ColorScheme < ActiveRecord::Base
- def self.themes
+ CUSTOM_SCHEMES = {
+ dark: {
+ "primary" => 'dddddd',
+ "secondary" => '222222',
+ "tertiary" => '0f82af',
+ "quaternary" => 'c14924',
+ "header_background" => '111111',
+ "header_primary" => '333333',
+ "highlight" => 'a87137',
+ "danger" => 'e45735',
+ "success" => '1ca551',
+ "love" => 'fa6c8d'
+ }
+ }
+
+ def self.base_color_scheme_colors
base_with_hash = {}
base_colors.each do |name, color|
- base_with_hash[name] = "##{color}"
+ base_with_hash[name] = "#{color}"
end
- [
- { id: 'default', colors: base_with_hash },
- {
- id: 'dark',
- colors: {
- "primary" => '#dddddd',
- "secondary" => '#222222',
- "tertiary" => '#0f82af',
- "quaternary" => '#c14924',
- "header_background" => '#111111',
- "header_primary" => '#333333',
- "highlight" => '#a87137',
- "danger" => '#e45735',
- "success" => '#1ca551',
- "love" => '#fa6c8d'
- }
- }
+ list = [
+ { id: 'default', colors: base_with_hash }
]
+
+ CUSTOM_SCHEMES.each do |k,v|
+ list.push({id: k.to_s, colors: v})
+ end
+ list
end
def self.hex_cache
@@ -39,9 +43,12 @@ class ColorScheme < ActiveRecord::Base
alias_method :colors, :color_scheme_colors
- scope :current_version, ->{ where(versioned_id: nil) }
+ before_save do
+ if self.id
+ self.version += 1
+ end
+ end
- after_destroy :destroy_versions
after_save :publish_discourse_stylesheet
after_save :dump_hex_cache
after_destroy :dump_hex_cache
@@ -64,13 +71,18 @@ class ColorScheme < ActiveRecord::Base
@base_colors
end
- def self.enabled
- current_version.find_by(enabled: true)
+ def self.base_color_schemes
+ base_color_scheme_colors.map do |hash|
+ scheme = new(name: I18n.t("color_schemes.#{hash[:id]}"), base_scheme_id: hash[:id])
+ scheme.colors = hash[:colors].map{|k,v| {name: k.to_s, hex: v.sub("#","")}}
+ scheme.is_base = true
+ scheme
+ end
end
def self.base
return @base_color_scheme if @base_color_scheme
- @base_color_scheme = new(name: I18n.t('color_schemes.base_theme_name'), enabled: false)
+ @base_color_scheme = new(name: I18n.t('color_schemes.base_theme_name'))
@base_color_scheme.colors = base_colors.map { |name, hex| {name: name, hex: hex} }
@base_color_scheme.is_base = true
@base_color_scheme
@@ -101,7 +113,7 @@ class ColorScheme < ActiveRecord::Base
end
# Can't use `where` here because base doesn't allow it
- (enabled || base).colors.find {|c| c.name == name }.try(:hex) || :nil
+ (base).colors.find {|c| c.name == name }.try(:hex) || :nil
end
def self.hex_for_name(name)
@@ -129,17 +141,39 @@ class ColorScheme < ActiveRecord::Base
end
end
- def previous_version
- ColorScheme.where(versioned_id: self.id).where('version < ?', self.version).order('version DESC').first
+ def base_colors
+ colors = nil
+ if base_scheme_id && base_scheme_id != "default"
+ colors = CUSTOM_SCHEMES[base_scheme_id.to_sym]
+ end
+ colors || ColorScheme.base_colors
end
- def destroy_versions
- ColorScheme.where(versioned_id: self.id).destroy_all
+ def resolved_colors
+ resolved = ColorScheme.base_colors.dup
+ if base_scheme_id && base_scheme_id != "default"
+ if scheme = CUSTOM_SCHEMES[base_scheme_id.to_sym]
+ scheme.each do |name, value|
+ resolved[name] = value
+ end
+ end
+ end
+ colors.each do |c|
+ resolved[c.name] = c.hex
+ end
+ resolved
end
def publish_discourse_stylesheet
- MessageBus.publish("/discourse_stylesheet", self.name)
- DiscourseStylesheets.cache.clear
+ if self.id
+ themes = Theme.where(color_scheme_id: self.id).to_a
+ if themes.present?
+ Stylesheet::Manager.cache.clear
+ themes.each do |theme|
+ theme.notify_scheme_change(_clear_manager_cache = false)
+ end
+ end
+ end
end
def dump_hex_cache
@@ -152,13 +186,11 @@ end
#
# Table name: color_schemes
#
-# id :integer not null, primary key
-# name :string not null
-# enabled :boolean default(FALSE), not null
-# versioned_id :integer
-# version :integer default(1), not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# via_wizard :boolean default(FALSE), not null
-# theme_id :string
+# id :integer not null, primary key
+# name :string not null
+# version :integer default(1), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# via_wizard :boolean default(FALSE), not null
+# base_scheme_id :string
#
diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb
new file mode 100644
index 00000000000..34df0657997
--- /dev/null
+++ b/app/models/remote_theme.rb
@@ -0,0 +1,85 @@
+require_dependency 'git_importer'
+
+class RemoteTheme < ActiveRecord::Base
+ has_one :theme
+
+ def self.import_theme(url, user=Discourse.system_user)
+ importer = GitImporter.new(url)
+ importer.import!
+
+ theme_info = JSON.parse(importer["about.json"])
+ theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"])
+
+ remote_theme = new
+ theme.remote_theme = remote_theme
+
+ remote_theme.remote_url = importer.url
+ remote_theme.update_from_remote(importer)
+
+ theme.save!
+ theme
+ ensure
+ begin
+ importer.cleanup!
+ rescue => e
+ Rails.logger.warn("Failed cleanup remote git #{e}")
+ end
+ end
+
+ def update_remote_version
+ importer = GitImporter.new(remote_url)
+ importer.import!
+ self.updated_at = Time.zone.now
+ self.remote_version, self.commits_behind = importer.commits_since(remote_version)
+ end
+
+ def update_from_remote(importer=nil)
+ return unless remote_url
+ cleanup = false
+ unless importer
+ cleanup = true
+ importer = GitImporter.new(remote_url)
+ importer.import!
+ end
+
+ Theme.targets.keys.each do |target|
+ Theme::ALLOWED_FIELDS.each do |field|
+ value = importer["#{target}/#{field=="scss"?"#{target}.scss":"#{field}.html"}"]
+ theme.set_field(target.to_sym, field, value)
+ end
+ end
+
+ theme_info = JSON.parse(importer["about.json"])
+ self.license_url ||= theme_info["license_url"]
+ self.about_url ||= theme_info["about_url"]
+
+ self.remote_updated_at = Time.zone.now
+ self.remote_version = importer.version
+ self.local_version = importer.version
+ self.commits_behind = 0
+
+ self
+ ensure
+ begin
+ importer.cleanup! if cleanup
+ rescue => e
+ Rails.logger.warn("Failed cleanup remote git #{e}")
+ end
+ end
+end
+
+# == Schema Information
+#
+# Table name: remote_themes
+#
+# id :integer not null, primary key
+# remote_url :string not null
+# remote_version :string
+# local_version :string
+# about_url :string
+# license_url :string
+# commits_behind :integer
+# remote_updated_at :datetime
+# created_at :datetime
+# updated_at :datetime
+#
diff --git a/app/models/site_customization.rb b/app/models/site_customization.rb
deleted file mode 100644
index 4b2e0ab988e..00000000000
--- a/app/models/site_customization.rb
+++ /dev/null
@@ -1,299 +0,0 @@
-require_dependency 'sass/discourse_sass_compiler'
-require_dependency 'sass/discourse_stylesheets'
-require_dependency 'distributed_cache'
-
-class SiteCustomization < ActiveRecord::Base
- ENABLED_KEY = '7e202ef2-56d7-47d5-98d8-a9c8d15e57dd'
-
- COMPILER_VERSION = 4
-
- @cache = DistributedCache.new('site_customization')
-
- def self.css_fields
- %w(stylesheet mobile_stylesheet embedded_css)
- end
-
- def self.html_fields
- %w(body_tag head_tag header mobile_header footer mobile_footer)
- end
-
- before_create do
- self.enabled ||= false
- self.key ||= SecureRandom.uuid
- true
- end
-
- def compile_stylesheet(scss)
- DiscourseSassCompiler.compile("@import \"theme_variables\";\n" << scss, 'custom')
- rescue => e
- puts e.backtrace.join("\n") unless Sass::SyntaxError === e
- raise e
- end
-
- def transpile(es6_source, version)
- template = Tilt::ES6ModuleTranspilerTemplate.new {}
- wrapped = <
{
- #{es6_source}
-});
-PLUGIN_API_JS
-
- template.babel_transpile(wrapped)
- end
-
- def process_html(html)
- doc = Nokogiri::HTML.fragment(html)
- doc.css('script[type="text/x-handlebars"]').each do |node|
- name = node["name"] || node["data-template-name"] || "broken"
- is_raw = name =~ /\.raw$/
- if is_raw
- template = "require('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})"
- node.replace <
- (function() {
- Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
- })();
-
-COMPILED
- else
- template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})"
- node.replace <
- (function() {
- Ember.TEMPLATES[#{name.inspect}] = #{template};
- })();
-
-COMPILED
- end
-
- end
-
- doc.css('script[type="text/discourse-plugin"]').each do |node|
- if node['version'].present?
- begin
- code = transpile(node.inner_html, node['version'])
- node.replace("")
- rescue MiniRacer::RuntimeError => ex
- node.replace("")
- end
- end
- end
-
- doc.to_s
- end
-
- before_save do
- SiteCustomization.html_fields.each do |html_attr|
- if self.send("#{html_attr}_changed?")
- self.send("#{html_attr}_baked=", process_html(self.send(html_attr)))
- end
- end
-
- SiteCustomization.css_fields.each do |stylesheet_attr|
- if self.send("#{stylesheet_attr}_changed?")
- begin
- self.send("#{stylesheet_attr}_baked=", compile_stylesheet(self.send(stylesheet_attr)))
- rescue Sass::SyntaxError => e
- self.send("#{stylesheet_attr}_baked=", DiscourseSassCompiler.error_as_css(e, "custom stylesheet"))
- end
- end
- end
- end
-
- def any_stylesheet_changed?
- SiteCustomization.css_fields.each do |fieldname|
- return true if self.send("#{fieldname}_changed?")
- end
- false
- end
-
- after_save do
- remove_from_cache!
- if any_stylesheet_changed?
- MessageBus.publish "/file-change/#{key}", SecureRandom.hex
- MessageBus.publish "/file-change/#{SiteCustomization::ENABLED_KEY}", SecureRandom.hex
- end
- MessageBus.publish "/header-change/#{key}", header if header_changed?
- MessageBus.publish "/footer-change/#{key}", footer if footer_changed?
- DiscourseStylesheets.cache.clear
- end
-
- after_destroy do
- remove_from_cache!
- end
-
- def self.enabled_key
- ENABLED_KEY.dup << RailsMultisite::ConnectionManagement.current_db
- end
-
- def self.field_for_target(target=nil)
- target ||= :desktop
-
- case target.to_sym
- when :mobile then :mobile_stylesheet
- when :desktop then :stylesheet
- when :embedded then :embedded_css
- end
- end
-
- def self.baked_for_target(target=nil)
- "#{field_for_target(target)}_baked".to_sym
- end
-
- def self.enabled_stylesheet_contents(target=:desktop)
- @cache["enabled_stylesheet_#{target}:#{COMPILER_VERSION}"] ||= where(enabled: true)
- .order(:name)
- .pluck(baked_for_target(target))
- .compact
- .join("\n")
- end
-
- def self.stylesheet_contents(key, target)
- if key == ENABLED_KEY
- enabled_stylesheet_contents(target)
- else
- where(key: key)
- .pluck(baked_for_target(target))
- .first
- end
- end
-
- def self.custom_stylesheet(preview_style=nil, target=:desktop)
- preview_style ||= ENABLED_KEY
- if preview_style == ENABLED_KEY
- stylesheet_link_tag(ENABLED_KEY, target, enabled_stylesheet_contents(target))
- else
- lookup_field(preview_style, target, :stylesheet_link_tag)
- end
- end
-
- %i{header top footer head_tag body_tag}.each do |name|
- define_singleton_method("custom_#{name}") do |preview_style=nil, target=:desktop|
- preview_style ||= ENABLED_KEY
- lookup_field(preview_style, target, name)
- end
- end
-
- def self.lookup_field(key, target, field)
- return if key.blank?
-
- cache_key = "#{key}:#{target}:#{field}:#{COMPILER_VERSION}"
-
- lookup = @cache[cache_key]
- return lookup.html_safe if lookup
-
- styles = if key == ENABLED_KEY
- order(:name).where(enabled:true).to_a
- else
- [find_by(key: key)].compact
- end
-
- val = if styles.present?
- styles.map do |style|
- lookup = target == :mobile ? "mobile_#{field}" : field
- if html_fields.include?(lookup.to_s)
- style.ensure_baked!(lookup)
- style.send("#{lookup}_baked")
- else
- style.send(lookup)
- end
- end.compact.join("\n")
- end
-
- (@cache[cache_key] = val || "").html_safe
- end
-
- def self.remove_from_cache!(key, broadcast = true)
- MessageBus.publish('/site_customization', key: key) if broadcast
- clear_cache!
- end
-
- def self.clear_cache!
- @cache.clear
- end
-
- def ensure_baked!(field)
-
- # If the version number changes, clear out all the baked fields
- if compiler_version != COMPILER_VERSION
- updates = { compiler_version: COMPILER_VERSION }
- SiteCustomization.html_fields.each do |f|
- updates["#{f}_baked".to_sym] = nil
- end
-
- update_columns(updates)
- end
-
- baked = send("#{field}_baked")
- if baked.blank?
- if val = self.send(field)
- val = process_html(val) rescue ""
- self.update_columns("#{field}_baked" => val)
- end
- end
- end
-
- def remove_from_cache!
- self.class.remove_from_cache!(self.class.enabled_key)
- self.class.remove_from_cache!(key)
- end
-
- def mobile_stylesheet_link_tag
- stylesheet_link_tag(:mobile)
- end
-
- def stylesheet_link_tag(target=:desktop)
- content = self.send(SiteCustomization.field_for_target(target))
- SiteCustomization.stylesheet_link_tag(key, target, content)
- end
-
- def self.stylesheet_link_tag(key, target, content)
- return "" unless content.present?
-
- hash = Digest::MD5.hexdigest(content)
- link_css_tag "/site_customizations/#{key}.css?target=#{target}&v=#{hash}"
- end
-
- def self.link_css_tag(href)
- href = (GlobalSetting.cdn_url || "") + "#{GlobalSetting.relative_url_root}#{href}&__ws=#{Discourse.current_hostname}"
- %Q{}.html_safe
- end
-end
-
-# == Schema Information
-#
-# Table name: site_customizations
-#
-# id :integer not null, primary key
-# name :string not null
-# stylesheet :text
-# header :text
-# user_id :integer not null
-# enabled :boolean not null
-# key :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# stylesheet_baked :text default(""), not null
-# mobile_stylesheet :text
-# mobile_header :text
-# mobile_stylesheet_baked :text
-# footer :text
-# mobile_footer :text
-# head_tag :text
-# body_tag :text
-# top :text
-# mobile_top :text
-# embedded_css :text
-# embedded_css_baked :text
-# head_tag_baked :text
-# body_tag_baked :text
-# header_baked :text
-# mobile_header_baked :text
-# footer_baked :text
-# mobile_footer_baked :text
-# compiler_version :integer default(0), not null
-#
-# Indexes
-#
-# index_site_customizations_on_key (key)
-#
diff --git a/app/models/stylesheet_cache.rb b/app/models/stylesheet_cache.rb
index c9dfa867623..2c568072cbf 100644
--- a/app/models/stylesheet_cache.rb
+++ b/app/models/stylesheet_cache.rb
@@ -3,11 +3,11 @@ class StylesheetCache < ActiveRecord::Base
MAX_TO_KEEP = 50
- def self.add(target,digest,content)
+ def self.add(target,digest,content,source_map)
return false if where(target: target, digest: digest).exists?
- success = create(target: target, digest: digest, content: content)
+ success = create(target: target, digest: digest, content: content, source_map: source_map)
count = StylesheetCache.count
if count > MAX_TO_KEEP
@@ -39,6 +39,8 @@ end
# content :text not null
# created_at :datetime
# updated_at :datetime
+# theme_id :integer default(-1), not null
+# source_map :text
#
# Indexes
#
diff --git a/app/models/theme.rb b/app/models/theme.rb
new file mode 100644
index 00000000000..32308c662fe
--- /dev/null
+++ b/app/models/theme.rb
@@ -0,0 +1,256 @@
+require_dependency 'distributed_cache'
+require_dependency 'stylesheet/compiler'
+require_dependency 'stylesheet/manager'
+
+class Theme < ActiveRecord::Base
+
+ ALLOWED_FIELDS = %w{scss head_tag header after_header body_tag footer}
+
+ @cache = DistributedCache.new('theme')
+
+ belongs_to :color_scheme
+ has_many :theme_fields, dependent: :destroy
+ has_many :child_theme_relation, class_name: 'ChildTheme', foreign_key: 'parent_theme_id', dependent: :destroy
+ has_many :child_themes, through: :child_theme_relation, source: :child_theme
+ belongs_to :remote_theme
+
+ before_create do
+ self.key ||= SecureRandom.uuid
+ true
+ end
+
+ after_save do
+ changed_fields.each(&:save!)
+ changed_fields.clear
+
+ Theme.expire_site_cache! if user_selectable_changed?
+
+ @dependant_themes = nil
+ @included_themes = nil
+ end
+
+ after_save do
+ remove_from_cache!
+ notify_scheme_change if color_scheme_id_changed?
+ end
+
+ after_destroy do
+ remove_from_cache!
+ if SiteSetting.default_theme_key == self.key
+ Theme.clear_default!
+ end
+ end
+
+ after_commit ->(theme) do
+ theme.notify_theme_change
+ end, on: :update
+
+ def self.expire_site_cache!
+ Site.clear_anon_cache!
+ ApplicationSerializer.expire_cache_fragment!("user_themes")
+ end
+
+ def self.clear_default!
+ SiteSetting.default_theme_key = ""
+ expire_site_cache!
+ end
+
+ def set_default!
+ SiteSetting.default_theme_key = key
+ Theme.expire_site_cache!
+ end
+
+ def self.lookup_field(key, target, field)
+ return if key.blank?
+
+ cache_key = "#{key}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}"
+ lookup = @cache[cache_key]
+ return lookup.html_safe if lookup
+
+ target = target.to_sym
+ theme = find_by(key: key)
+
+ val = theme.resolve_baked_field(target, field) if theme
+
+ (@cache[cache_key] = val || "").html_safe
+ end
+
+ def self.remove_from_cache!(themes=nil)
+ clear_cache!
+ end
+
+ def self.clear_cache!
+ @cache.clear
+ end
+
+
+ def self.targets
+ @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2)
+ end
+
+
+ def notify_scheme_change(clear_manager_cache=true)
+ Stylesheet::Manager.cache.clear if clear_manager_cache
+ message = refresh_message_for_targets(["desktop", "mobile", "admin"], self.color_scheme_id, self, Rails.env.development?)
+ MessageBus.publish('/file-change', message)
+ end
+
+ def notify_theme_change
+ Stylesheet::Manager.clear_theme_cache!
+
+ themes = [self] + dependant_themes
+
+ message = themes.map do |theme|
+ refresh_message_for_targets([:mobile_theme,:desktop_theme], theme.id, theme)
+ end.compact.flatten
+ MessageBus.publish('/file-change', message)
+ end
+
+ def refresh_message_for_targets(targets, id, theme, add_cache_breaker=false)
+ targets.map do |target|
+ link = Stylesheet::Manager.stylesheet_link_tag(target.to_sym, 'all', theme.key)
+ if link
+ href = link.split(/["']/)[1]
+ if add_cache_breaker
+ href << (href.include?("?") ? "&" : "?")
+ href << SecureRandom.hex
+ end
+ {
+ name: "/stylesheets/#{target}#{id ? "_#{id}": ""}",
+ new_href: href
+ }
+ end
+ end
+ end
+
+ def dependant_themes
+ @dependant_themes ||= resolve_dependant_themes(:up)
+ end
+
+ def included_themes
+ @included_themes ||= resolve_dependant_themes(:down)
+ end
+
+ def resolve_dependant_themes(direction)
+
+ select_field,where_field=nil
+
+ if direction == :up
+ select_field = "parent_theme_id"
+ where_field = "child_theme_id"
+ elsif direction == :down
+ select_field = "child_theme_id"
+ where_field = "parent_theme_id"
+ else
+ raise "Unknown direction"
+ end
+
+ themes = []
+ return [] unless id
+
+ uniq = Set.new
+ uniq << id
+
+ iterations = 0
+ added = [id]
+
+ while added.length > 0 && iterations < 5
+
+ iterations += 1
+
+ new_themes = Theme.where("id in (SELECT #{select_field}
+ FROM child_themes
+ WHERE #{where_field} in (?))", added).to_a
+
+ added = []
+ new_themes.each do |theme|
+ unless uniq.include?(theme.id)
+ added << theme.id
+ uniq << theme.id
+ themes << theme
+ end
+ end
+
+ end
+
+ themes
+ end
+
+ def resolve_baked_field(target, name)
+ list_baked_fields(target,name).map{|f| f.value_baked || f.value}.join("\n")
+ end
+
+ def list_baked_fields(target, name)
+
+ target = target.to_sym
+
+ theme_ids = [self.id] + (included_themes.map(&:id) || [])
+ fields = ThemeField.where(target: [Theme.targets[target], Theme.targets[:common]])
+ .where(name: name.to_s)
+ .includes(:theme)
+ .joins("JOIN (
+ SELECT #{theme_ids.map.with_index{|id,idx| "#{id} AS theme_id, #{idx} AS sort_column"}.join(" UNION ALL SELECT ")}
+ ) as X ON X.theme_id = theme_fields.theme_id")
+ .order('sort_column, target')
+ fields.each(&:ensure_baked!)
+ fields
+ end
+
+ def remove_from_cache!
+ self.class.remove_from_cache!
+ end
+
+ def changed_fields
+ @changed_fields ||= []
+ end
+
+ def set_field(target, name, value)
+ name = name.to_s
+
+ target_id = Theme.targets[target.to_sym]
+ raise "Unknown target #{target} passed to set field" unless target_id
+
+ field = theme_fields.find{|f| f.name==name && f.target == target_id}
+ if field
+ if value.blank?
+ field.destroy
+ else
+ if field.value != value
+ field.value = value
+ changed_fields << field
+ end
+ end
+ else
+ theme_fields.build(target: target_id, value: value, name: name) if value.present?
+ end
+ end
+
+ def add_child_theme!(theme)
+ child_theme_relation.create!(child_theme_id: theme.id)
+ @included_themes = nil
+ child_themes.reload
+ save!
+ end
+end
+
+# == Schema Information
+#
+# Table name: themes
+#
+# id :integer not null, primary key
+# name :string not null
+# user_id :integer not null
+# key :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# compiler_version :integer default(0), not null
+# user_selectable :boolean default(FALSE), not null
+# hidden :boolean default(FALSE), not null
+# color_scheme_id :integer
+# remote_theme_id :integer
+#
+# Indexes
+#
+# index_themes_on_key (key)
+# index_themes_on_remote_theme_id (remote_theme_id) UNIQUE
+#
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
new file mode 100644
index 00000000000..ead11507aff
--- /dev/null
+++ b/app/models/theme_field.rb
@@ -0,0 +1,117 @@
+class ThemeField < ActiveRecord::Base
+
+ COMPILER_VERSION = 5
+
+ belongs_to :theme
+
+ def transpile(es6_source, version)
+ template = Tilt::ES6ModuleTranspilerTemplate.new {}
+ wrapped = < {
+ #{es6_source}
+});
+PLUGIN_API_JS
+
+ template.babel_transpile(wrapped)
+ end
+
+ def process_html(html)
+ doc = Nokogiri::HTML.fragment(html)
+ doc.css('script[type="text/x-handlebars"]').each do |node|
+ name = node["name"] || node["data-template-name"] || "broken"
+ is_raw = name =~ /\.raw$/
+ if is_raw
+ template = "require('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})"
+ node.replace <
+ (function() {
+ Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
+ })();
+
+COMPILED
+ else
+ template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})"
+ node.replace <
+ (function() {
+ Ember.TEMPLATES[#{name.inspect}] = #{template};
+ })();
+
+COMPILED
+ end
+
+ end
+
+ doc.css('script[type="text/discourse-plugin"]').each do |node|
+ if node['version'].present?
+ begin
+ code = transpile(node.inner_html, node['version'])
+ node.replace("")
+ rescue MiniRacer::RuntimeError => ex
+ node.replace("")
+ end
+ end
+ end
+
+ doc.to_s
+ end
+
+
+ def self.html_fields
+ %w(body_tag head_tag header footer after_header)
+ end
+
+
+ def ensure_baked!
+ if ThemeField.html_fields.include?(self.name)
+ if !self.value_baked || compiler_version != COMPILER_VERSION
+
+ self.value_baked = process_html(self.value)
+ self.compiler_version = COMPILER_VERSION
+
+ if self.value_baked_changed? || compiler_version.changed?
+ self.update_columns(value_baked: value_baked, compiler_version: compiler_version)
+ end
+ end
+ end
+ end
+
+ def target_name
+ Theme.targets.invert[target].to_s
+ end
+
+ before_save do
+ if value_changed? && !value_baked_changed?
+ self.value_baked = nil
+ end
+ end
+
+ after_commit do
+ ensure_baked!
+
+ Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss")
+
+ # TODO message for mobile vs desktop
+ MessageBus.publish "/header-change/#{theme.key}", self.value if self.name == "header"
+ MessageBus.publish "/footer-change/#{theme.key}", self.value if self.name == "footer"
+ end
+end
+
+# == Schema Information
+#
+# Table name: theme_fields
+#
+# id :integer not null, primary key
+# theme_id :integer not null
+# target :integer not null
+# name :string not null
+# value :text not null
+# value_baked :text
+# created_at :datetime
+# updated_at :datetime
+# compiler_version :integer default(0), not null
+#
+# Indexes
+#
+# index_theme_fields_on_theme_id_and_target_and_name (theme_id,target,name) UNIQUE
+#
diff --git a/app/models/user_history.rb b/app/models/user_history.rb
index a6677de389d..e177cd8de4a 100644
--- a/app/models/user_history.rb
+++ b/app/models/user_history.rb
@@ -19,8 +19,8 @@ class UserHistory < ActiveRecord::Base
@actions ||= Enum.new(delete_user: 1,
change_trust_level: 2,
change_site_setting: 3,
- change_site_customization: 4,
- delete_site_customization: 5,
+ change_theme: 4,
+ delete_theme: 5,
checked_for_custom_avatar: 6, # not used anymore
notified_about_avatar: 7,
notified_about_sequential_replies: 8,
@@ -71,8 +71,8 @@ class UserHistory < ActiveRecord::Base
@staff_actions ||= [:delete_user,
:change_trust_level,
:change_site_setting,
- :change_site_customization,
- :delete_site_customization,
+ :change_theme,
+ :delete_theme,
:change_site_text,
:suspend_user,
:unsuspend_user,
@@ -158,7 +158,7 @@ class UserHistory < ActiveRecord::Base
end
def new_value_is_json?
- [UserHistory.actions[:change_site_customization], UserHistory.actions[:delete_site_customization]].include?(action)
+ [UserHistory.actions[:change_theme], UserHistory.actions[:delete_theme]].include?(action)
end
def previous_value_is_json?
diff --git a/app/serializers/color_scheme_color_serializer.rb b/app/serializers/color_scheme_color_serializer.rb
index b1d3d809b69..3e99c06cf20 100644
--- a/app/serializers/color_scheme_color_serializer.rb
+++ b/app/serializers/color_scheme_color_serializer.rb
@@ -6,6 +6,11 @@ class ColorSchemeColorSerializer < ApplicationSerializer
end
def default_hex
- ColorScheme.base_colors[object.name]
+ if object.color_scheme
+ object.color_scheme.base_colors[object.name]
+ else
+ # it is a base color so it is already default
+ object.hex
+ end
end
end
diff --git a/app/serializers/color_scheme_serializer.rb b/app/serializers/color_scheme_serializer.rb
index 965d5923778..2d5ab79d396 100644
--- a/app/serializers/color_scheme_serializer.rb
+++ b/app/serializers/color_scheme_serializer.rb
@@ -1,8 +1,4 @@
class ColorSchemeSerializer < ApplicationSerializer
- attributes :id, :name, :enabled, :is_base
+ attributes :id, :name, :is_base, :base_scheme_id
has_many :colors, serializer: ColorSchemeColorSerializer, embed: :objects
-
- def base
- object.is_base || false
- end
end
diff --git a/app/serializers/site_customization_serializer.rb b/app/serializers/site_customization_serializer.rb
deleted file mode 100644
index 6a3e70ff211..00000000000
--- a/app/serializers/site_customization_serializer.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class SiteCustomizationSerializer < ApplicationSerializer
-
- attributes :id, :name, :key, :enabled, :created_at, :updated_at,
- :stylesheet, :header, :footer, :top,
- :mobile_stylesheet, :mobile_header, :mobile_footer, :mobile_top,
- :head_tag, :body_tag, :embedded_css
-end
diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb
index d43c165a96e..5ea12e8cbe7 100644
--- a/app/serializers/site_serializer.rb
+++ b/app/serializers/site_serializer.rb
@@ -24,13 +24,25 @@ class SiteSerializer < ApplicationSerializer
:tags_filter_regexp,
:top_tags,
:wizard_required,
- :topic_featured_link_allowed_category_ids
+ :topic_featured_link_allowed_category_ids,
+ :user_themes
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :trust_levels, embed: :objects
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
has_many :user_fields, embed: :objects, serialzer: UserFieldSerializer
+ def user_themes
+ cache_fragment("user_themes") do
+ Theme.where('key = :default OR user_selectable',
+ default: SiteSetting.default_theme_key)
+ .order(:name)
+ .pluck(:key, :name)
+ .map{|k,n| {theme_key: k, name: n, default: k == SiteSetting.default_theme_key}}
+ .as_json
+ end
+ end
+
def groups
cache_fragment("group_names") do
Group.order(:name).pluck(:id,:name).map { |id,name| { id: id, name: name } }.as_json
diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb
new file mode 100644
index 00000000000..051cf5d2f6d
--- /dev/null
+++ b/app/serializers/theme_serializer.rb
@@ -0,0 +1,42 @@
+class ThemeFieldSerializer < ApplicationSerializer
+ attributes :name, :target, :value
+
+ def target
+ case object.target
+ when 0 then "common"
+ when 1 then "desktop"
+ when 2 then "mobile"
+ end
+ end
+end
+
+class ChildThemeSerializer < ApplicationSerializer
+ attributes :id, :name, :key, :created_at, :updated_at, :default
+
+ def include_default?
+ object.key == SiteSetting.default_theme_key
+ end
+
+ def default
+ true
+ end
+end
+
+class RemoteThemeSerializer < ApplicationSerializer
+ attributes :id, :remote_url, :remote_version, :local_version, :about_url,
+ :license_url, :commits_behind, :remote_updated_at, :updated_at
+
+ # wow, AMS has some pretty nutty logic where it tries to find the path here
+ # from action dispatch, tell it not to
+ def about_url
+ object.about_url
+ end
+end
+
+class ThemeSerializer < ChildThemeSerializer
+ attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id
+
+ has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects
+ has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects
+ has_one :remote_theme, serializer: RemoteThemeSerializer, embed: :objects
+end
diff --git a/app/serializers/user_history_serializer.rb b/app/serializers/user_history_serializer.rb
index 039e25c61d0..f73d3e424b2 100644
--- a/app/serializers/user_history_serializer.rb
+++ b/app/serializers/user_history_serializer.rb
@@ -12,7 +12,8 @@ class UserHistorySerializer < ApplicationSerializer
:post_id,
:category_id,
:action,
- :custom_type
+ :custom_type,
+ :id
has_one :acting_user, serializer: BasicUserSerializer, embed: :objects
has_one :target_user, serializer: BasicUserSerializer, embed: :objects
diff --git a/app/services/color_scheme_revisor.rb b/app/services/color_scheme_revisor.rb
index 5db7bb79d1e..ce66c37332d 100644
--- a/app/services/color_scheme_revisor.rb
+++ b/app/services/color_scheme_revisor.rb
@@ -9,63 +9,26 @@ class ColorSchemeRevisor
self.new(color_scheme, params).revise
end
- def self.revert(color_scheme)
- self.new(color_scheme).revert
- end
-
def revise
ColorScheme.transaction do
- if @params[:enabled]
- ColorScheme.where('id != ?', @color_scheme.id).update_all enabled: false
- end
@color_scheme.name = @params[:name] if @params.has_key?(:name)
- @color_scheme.enabled = @params[:enabled] if @params.has_key?(:enabled)
- @color_scheme.theme_id = @params[:theme_id] if @params.has_key?(:theme_id)
- new_version = false
+ @color_scheme.base_scheme_id = @params[:base_scheme_id] if @params.has_key?(:base_scheme_id)
+ has_colors = @params[:colors]
- if @params[:colors]
- new_version = @params[:colors].any? do |c|
- (existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex]
- end
- end
-
- if new_version
- ColorScheme.create(
- name: @color_scheme.name,
- enabled: false,
- colors: @color_scheme.colors_hashes,
- versioned_id: @color_scheme.id,
- version: @color_scheme.version)
- @color_scheme.version += 1
- end
-
- if @params[:colors]
+ if has_colors
@params[:colors].each do |c|
if existing = @color_scheme.colors_by_name[c[:name]]
existing.update_attributes(c)
+ else
+ @color_scheme.color_scheme_colors << ColorSchemeColor.new(name: c[:name], hex: c[:hex])
end
end
- end
-
- @color_scheme.save
- @color_scheme.clear_colors_cache
- end
- @color_scheme
- end
-
- def revert
- ColorScheme.transaction do
- if prev = @color_scheme.previous_version
- @color_scheme.version = prev.version
- @color_scheme.colors.clear
- prev.colors.update_all(color_scheme_id: @color_scheme.id)
- prev.destroy
- @color_scheme.save!
@color_scheme.clear_colors_cache
end
- end
+ @color_scheme.save if has_colors || @color_scheme.name_changed? || @color_scheme.base_scheme_id_changed?
+ end
@color_scheme
end
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index 71734ab2e20..1cd09176af4 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -113,34 +113,49 @@ class StaffActionLogger
}))
end
- SITE_CUSTOMIZATION_LOGGED_ATTRS = [
- 'stylesheet', 'mobile_stylesheet',
- 'header', 'mobile_header',
- 'top', 'mobile_top',
- 'footer', 'mobile_footer',
- 'head_tag',
- 'body_tag',
- 'position',
- 'enabled',
- 'key'
- ]
+ def theme_json(theme)
+ ThemeSerializer.new(theme, root:false).to_json
+ end
+
+ def strip_duplicates(old,cur)
+ return [old,cur] unless old && cur
+
+ old = JSON.parse(old)
+ cur = JSON.parse(cur)
+
+ old.each do |k, v|
+ next if k == "name"
+ next if k == "id"
+ if (v == cur[k])
+ cur.delete(k)
+ old.delete(k)
+ end
+ end
+
+ [old.to_json, cur.to_json]
+ end
+
+ def log_theme_change(old_json, new_theme, opts={})
+ raise Discourse::InvalidParameters.new(:new_theme) unless new_theme
+
+ new_json = theme_json(new_theme)
+
+ old_json,new_json = strip_duplicates(old_json,new_json)
- def log_site_customization_change(old_record, site_customization_params, opts={})
- raise Discourse::InvalidParameters.new(:site_customization_params) unless site_customization_params
UserHistory.create( params(opts).merge({
- action: UserHistory.actions[:change_site_customization],
- subject: site_customization_params[:name],
- previous_value: old_record ? old_record.attributes.slice(*SITE_CUSTOMIZATION_LOGGED_ATTRS).to_json : nil,
- new_value: site_customization_params.slice(*(SITE_CUSTOMIZATION_LOGGED_ATTRS.map(&:to_sym))).to_json
+ action: UserHistory.actions[:change_theme],
+ subject: new_theme.name,
+ previous_value: old_json,
+ new_value: new_json
}))
end
- def log_site_customization_destroy(site_customization, opts={})
- raise Discourse::InvalidParameters.new(:site_customization) unless site_customization
+ def log_theme_destroy(theme, opts={})
+ raise Discourse::InvalidParameters.new(:theme) unless theme
UserHistory.create( params(opts).merge({
- action: UserHistory.actions[:delete_site_customization],
- subject: site_customization.name,
- previous_value: site_customization.attributes.slice(*SITE_CUSTOMIZATION_LOGGED_ATTRS).to_json
+ action: UserHistory.actions[:delete_theme],
+ subject: theme.name,
+ previous_value: theme_json(theme)
}))
end
diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb
index 702bbd9f029..044ce2fc767 100644
--- a/app/views/common/_discourse_stylesheet.html.erb
+++ b/app/views/common/_discourse_stylesheet.html.erb
@@ -1,13 +1,13 @@
<%- if rtl? %>
- <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %>
+ <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %>
<%- else %>
- <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %>
+ <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %>
<%- end %>
<%- if staff? %>
- <%= DiscourseStylesheets.stylesheet_link_tag(:admin) %>
+ <%= discourse_stylesheet_link_tag(:admin) %>
<%- end %>
-<%- unless customization_disabled? %>
- <%= SiteCustomization.custom_stylesheet(session[:preview_style], mobile_view? ? :mobile : :desktop) %>
+<%- if theme_key %>
+ <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %>
<%- end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 41b321f22d3..59100bee029 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -4,6 +4,7 @@
<%= content_for?(:title) ? yield(:title) + ' - ' + SiteSetting.title : SiteSetting.title %>
+
<%= render partial: "layouts/head" %>
<%= render partial: "common/special_font_face" %>
<%= render partial: "common/discourse_stylesheet" %>
@@ -41,7 +42,7 @@
<%- end %>
<%- unless customization_disabled? %>
- <%= raw SiteCustomization.custom_head_tag(session[:preview_style]) %>
+ <%= raw theme_lookup("head_tag") %>
<%- end %>
<%= render_google_universal_analytics_code %>
@@ -82,7 +83,7 @@
<%- unless customization_disabled? || loading_admin? %>
- <%= SiteCustomization.custom_header(session[:preview_style], mobile_view? ? :mobile : :desktop) %>
+ <%= theme_lookup("header") %>
<%- end %>
@@ -118,7 +119,7 @@
<%= render_google_analytics_code %>
<%- unless customization_disabled? %>
- <%= raw SiteCustomization.custom_body_tag(session[:preview_style]) %>
+ <%= raw theme_lookup("body_tag") %>
<%- end %>