FEATURE: Native theme support

This feature introduces the concept of themes. Themes are an evolution
of site customizations.

Themes introduce two very big conceptual changes:

- A theme may include other "child themes", children can include grand
children and so on.

- A theme may specify a color scheme

The change does away with the idea of "enabled" color schemes.

It also adds a bunch of big niceties like

- You can source a theme from a git repo

- History for themes is much improved

- You can only have a single enabled theme. Themes can be selected by
    users, if you opt for it.

On a technical level this change comes with a whole bunch of goodies

- All CSS is now compiled using a custom pipeline that uses libsass
    see /lib/stylesheet

- There is a single pipeline for css compilation (in the past we used
    one for customizations and another one for the rest of the app

- The stylesheet pipeline is now divorced of sprockets, there is no
   reliance on sprockets for CSS bundling

- CSS is generated with source maps everywhere (including themes) this
    makes debugging much easier

- Our "live reloader" is smarter and avoid a flash of unstyled content
   we run a file watcher in "puma" in dev so you no longer need to run
   rake autospec to watch for CSS changes
This commit is contained in:
Sam
2017-04-12 10:52:52 -04:00
parent 1a9afa976d
commit a3e8c3cd7b
163 changed files with 4415 additions and 2424 deletions
@@ -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');
});
}
});
}
}
});
@@ -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(); });
}
}
});
}
}
});
@@ -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);
}
}
});
@@ -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');
}
}
});
@@ -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');
});
}
});
},
}
});
@@ -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');
}
}
});
@@ -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');
});
}
}
});
@@ -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);
});
}
});
@@ -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');
}
}
});
@@ -1,7 +0,0 @@
import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details";
export default ChangeSiteCustomizationDetailsController.extend({
onShow() {
this.send("selectPrevious");
}
});