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

View File

@ -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

View File

@ -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

View File

@ -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
});

View File

@ -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;

View File

@ -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();
});
}
});

View File

@ -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')
});

View File

@ -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();
}
}
});

View File

@ -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');
});
}
});
}
}
});

View File

@ -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(); });
}
}
});
}
}
});

View File

@ -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);
}
}
});

View File

@ -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');
}
}
});

View File

@ -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');
});
}
});
},
}
});

View File

@ -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');
}
}
});

View File

@ -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');
});
}
}
});

View File

@ -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);
});
}
});

View File

@ -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');
}
}
});

View File

@ -1,7 +0,0 @@
import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details";
export default ChangeSiteCustomizationDetailsController.extend({
onShow() {
this.send("selectPrevious");
}
});

View File

@ -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;
}
});

View File

@ -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;

View File

@ -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')
});

View File

@ -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;

View File

@ -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'));
}
});

View File

@ -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);
}
});

View File

@ -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);
}
});

View File

@ -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);
}
}
});

View File

@ -1,5 +1,5 @@
export default Ember.Route.extend({
beforeModel() {
this.transitionTo('adminCustomize.colors');
this.transitionTo('adminCustomizeThemes');
}
});

View File

@ -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);
},
});

View File

@ -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"));
}
});

View File

@ -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);
}
}
});

View File

@ -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();
}
}
});

View File

@ -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() {

View File

@ -1,5 +0,0 @@
<li>
<a href="/admin/customize/css_html/{{customization.id}}/css" class="{{if active 'active'}}">
{{customization.description}}
</a>
</li>

View File

@ -0,0 +1,8 @@
<label class='checkbox-label'>
{{input type="checkbox" disabled=disabled checked=checkedInternal}}
{{label}}
</label>
{{#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}}

View File

@ -0,0 +1 @@
<p class="about">{{i18n 'admin.customize.colors.about'}}</p>

View File

@ -0,0 +1,53 @@
<div class="color-scheme show-current-style">
<div class="admin-container">
<h1>{{text-field class="style-name" value=model.name}}</h1>
<div class="controls">
<button {{action "save"}} disabled={{model.disableSave}} class='btn'>{{i18n 'admin.customize.save'}}</button>
<button {{action "copy" model}} class='btn'><i class="fa fa-copy"></i> {{i18n 'admin.customize.copy'}}</button>
<button {{action "destroy"}} class='btn btn-danger'><i class="fa fa-trash-o"></i> {{i18n 'admin.customize.delete'}}</button>
<span class="saving {{unless model.savingStatus 'hidden'}}">{{model.savingStatus}}</span>
</div>
<br/>
<div class='admin-controls'>
<div class='search controls'>
<label>
{{input type="checkbox" checked=onlyOverridden}}
{{i18n 'admin.site_settings.show_overriden'}}
</label>
</div>
</div>
{{#if colors.length}}
<table class="table colors">
<thead>
<tr>
<th></th>
<th class="hex">{{i18n 'admin.customize.color'}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each colors as |c|}}
<tr class="{{if c.changed 'changed'}} {{if c.valid 'valid' 'invalid'}}">
<td class="name" title={{c.name}}>
<b>{{c.translatedName}}</b>
<br/>
<span class="description">{{c.description}}</span>
</td>
<td class="hex">{{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}}</td>
<td class="actions">
<button class="btn revert {{unless c.savedIsOverriden 'invisible'}}" {{action "revert" c}} title="{{i18n 'admin.customize.colors.revert_title'}}">{{i18n 'revert'}}</button>
<button class="btn undo {{unless c.changed 'invisible'}}" {{action "undo" c}} title="{{i18n 'admin.customize.colors.undo_title'}}">{{i18n 'undo'}}</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n 'search.no_results'}}</p>
{{/if}}
</div>
</div>

View File

@ -3,76 +3,16 @@
<ul>
{{#each model as |scheme|}}
{{#unless scheme.is_base}}
<li><a {{action "selectColorScheme" scheme}} class="{{if scheme.selected 'active'}}">{{scheme.description}}</a></li>
<li>
{{#link-to 'adminCustomize.colors.show' scheme replace=true}}{{scheme.description}}{{/link-to}}
</li>
{{/unless}}
{{/each}}
</ul>
<button {{action "newColorScheme"}} class='btn'><i class="fa fa-plus"></i>{{i18n 'admin.customize.new'}}</button>
<button {{action "newColorScheme"}} class='btn'>{{fa-icon 'plus'}}{{i18n 'admin.customize.new'}}</button>
</div>
{{#if selectedItem}}
<div class="current-style color-scheme">
<div class="admin-container">
<h1>{{text-field class="style-name" value=selectedItem.name}}</h1>
<div class="controls">
<button {{action "save"}} disabled={{selectedItem.disableSave}} class='btn'>{{i18n 'admin.customize.save'}}</button>
<button {{action "toggleEnabled"}} disabled={{selectedItem.disableEnable}} class="btn">
{{#if selectedItem.enabled}}
{{i18n 'disable'}}
{{else}}
{{i18n 'enable'}}
{{/if}}
</button>
<button {{action "copy" selectedItem}} class='btn'><i class="fa fa-copy"></i> {{i18n 'admin.customize.copy'}}</button>
<button {{action "destroy"}} class='btn btn-danger'><i class="fa fa-trash-o"></i> {{i18n 'admin.customize.delete'}}</button>
<span class="saving {{unless selectedItem.savingStatus 'hidden'}}">{{selectedItem.savingStatus}}</span>
</div>
<br/>
<div class='admin-controls'>
<div class='search controls'>
<label>
{{input type="checkbox" checked=onlyOverridden}}
{{i18n 'admin.site_settings.show_overriden'}}
</label>
</div>
</div>
{{#if colors.length}}
<table class="table colors">
<thead>
<tr>
<th></th>
<th class="hex">{{i18n 'admin.customize.color'}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each colors as |c|}}
<tr class="{{if c.changed 'changed'}} {{if c.valid 'valid' 'invalid'}}">
<td class="name" title={{c.name}}>
<b>{{c.translatedName}}</b>
<br/>
<span class="description">{{c.description}}</span>
</td>
<td class="hex">{{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}}</td>
<td class="actions">
<button class="btn revert {{unless c.savedIsOverriden 'invisible'}}" {{action "revert" c}} title="{{i18n 'admin.customize.colors.revert_title'}}">{{i18n 'revert'}}</button>
<button class="btn undo {{unless c.changed 'invisible'}}" {{action "undo" c}} title="{{i18n 'admin.customize.colors.undo_title'}}">{{i18n 'undo'}}</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n 'search.no_results'}}</p>
{{/if}}
</div>
</div>
{{else}}
<p class="about">{{i18n 'admin.customize.colors.about'}}</p>
{{/if}}
{{outlet}}
<div class="clearfix"></div>

View File

@ -1,75 +0,0 @@
<div class="current-style {{if maximized 'maximized'}}">
<div class='wrapper'>
{{text-field class="style-name" value=model.name}}
<a class="btn export" target="_blank" href={{downloadUrl}}>{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a>
<div class='admin-controls'>
<ul class="nav nav-pills">
{{#if mobile}}
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-css' replace=true}}{{i18n "admin.customize.css"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-header' replace=true}}{{i18n "admin.customize.header"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-top' replace=true}}{{i18n "admin.customize.top"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'mobile-footer' replace=true}}{{i18n "admin.customize.footer"}}{{/link-to}}</li>
{{else}}
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'css' replace=true}}{{i18n "admin.customize.css"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'header' replace=true}}{{i18n "admin.customize.header"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'top' replace=true}}{{i18n "admin.customize.top"}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'footer' replace=true}}{{i18n "admin.customize.footer"}}{{/link-to}}</li>
<li>
{{#link-to 'adminCustomizeCssHtml.show' model.id 'head-tag'}}
{{fa-icon "file-text-o"}}&nbsp;{{i18n 'admin.customize.head_tag.text'}}
{{/link-to}}
</li>
<li>
{{#link-to 'adminCustomizeCssHtml.show' model.id 'body-tag'}}
{{fa-icon "file-text-o"}}&nbsp;{{i18n 'admin.customize.body_tag.text'}}
{{/link-to}}
</li>
<li>{{#link-to 'adminCustomizeCssHtml.show' model.id 'embedded-css' replace=true}}{{i18n "admin.customize.embedded_css"}}{{/link-to}}</li>
{{/if}}
<li class='toggle-mobile'>
<a class="{{if mobile 'active'}}" {{action "toggleMobile"}}>{{fa-icon "mobile"}}</a>
</li>
<li class='toggle-maximize'>
<a {{action "toggleMaximize"}}>
<i class="fa fa-{{maximizeIcon}}"></i>
</a>
</li>
</ul>
</div>
<div class="admin-container">
{{#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}}
</div>
<div class='admin-footer'>
<div class='status-actions'>
<span>{{i18n 'admin.customize.enabled'}} {{input type="checkbox" checked=model.enabled}}</span>
{{#unless model.changed}}
<a class='preview-link' href={{previewUrl}} target='_blank' title="{{i18n 'admin.customize.explain_preview'}}">{{i18n 'admin.customize.preview'}}</a>
|
<a href={{undoPreviewUrl}} target='_blank' title="{{i18n 'admin.customize.explain_undo_preview'}}">{{i18n 'admin.customize.undo_preview'}}</a>
|
<a href={{defaultStyleUrl}} target='_blank' title="{{i18n 'admin.customize.explain_rescue_preview'}}">{{i18n 'admin.customize.rescue_preview'}}</a><br>
{{/unless}}
</div>
<div class='buttons'>
{{#d-button action="save" disabled=saveDisabled class='btn-primary'}}
{{saveButtonText}}
{{/d-button}}
{{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}}
</div>
</div>
</div>
</div>

View File

@ -1,13 +0,0 @@
<div class='content-list span6'>
<h3>{{i18n 'admin.customize.css_html.long_title'}}</h3>
<ul>
{{#each model as |c|}}
{{customize-link customization=c}}
{{/each}}
</ul>
{{d-button label="admin.customize.new" icon="plus" action="newCustomization" class="btn-primary"}}
{{d-button action="importModal" icon="upload" label="admin.customize.import"}}
</div>
{{outlet}}

View File

@ -0,0 +1,62 @@
<div class="current-style {{if maximized 'maximized'}}">
<div class='wrapper'>
<h2>{{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}</h2>
<ul class='nav nav-pills target'>
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id 'common' fieldName replace=true title=field.title}}
{{i18n 'admin.customize.theme.common'}}
{{/link-to}}
</li>
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true title=field.title}}
{{i18n 'admin.customize.theme.desktop'}}
{{fa-icon 'desktop'}}
{{/link-to}}
</li>
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true title=field.title}}
{{i18n 'admin.customize.theme.mobile'}}
{{fa-icon 'mobile'}}
{{/link-to}}
</li>
</ul>
<div class='admin-controls'>
<ul class='nav nav-pills fields'>
{{#each fields as |field|}}
<li>
{{#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}}
</li>
{{/each}}
<li class='toggle-maximize'>
<a {{action "toggleMaximize"}}>
<i class="fa fa-{{maximizeIcon}}"></i>
</a>
</li>
</ul>
</div>
<div>
<div class='custom-ace-gutter'></div>
{{ace-editor content=activeSection mode=activeSectionMode}}
</div>
<div class='admin-footer'>
<div class='status-actions'>
{{#unless model.changed}}
<a class='preview-link' href={{previewUrl}} target='_blank' title="{{i18n 'admin.customize.explain_preview'}}">{{i18n 'admin.customize.preview'}}</a>
{{/unless}}
</div>
<div class='buttons'>
{{#d-button action="save" disabled=saveDisabled class='btn-primary'}}
{{saveButtonText}}
{{/d-button}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,112 @@
<div class="show-current-style">
<h2>
{{#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}} <a {{action "startEditingName"}}>{{fa-icon "pencil"}}</a>
{{/if}}
</h2>
{{#if model.remote_theme}}
<p>
<a href="{{model.remote_theme.about_url}}">{{i18n "admin.customize.theme.about_theme"}}</a>
</p>
{{#if model.remote_theme.license_url}}
<p>
<a href="{{model.remote_theme.license_url}}">{{i18n "admin.customize.theme.license"}} {{fa-icon "copyright"}}</a>
</p>
{{/if}}
{{/if}}
<p>
{{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}}
</p>
{{#if showSchemes}}
<h3>{{i18n "admin.customize.theme.color_scheme"}}</h3>
<p>{{i18n "admin.customize.theme.color_scheme_select"}}</p>
<p>{{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}}
</p>
{{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}}
{{/if}}
<h3>{{i18n "admin.customize.theme.css_html"}}</h3>
{{#if hasEditedFields}}
<p>{{i18n "admin.customize.theme.custom_sections"}}</p>
<ul>
{{#each editedDescriptions as |desc|}}
<li>{{desc}}</li>
{{/each}}
</ul>
{{else}}
<p>
{{i18n "admin.customize.theme.edit_css_html_help"}}
</p>
{{/if}}
<p>
{{#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}}
<span class='status-message'>
{{#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}}
</span>
{{/if}}
</p>
{{#if availableChildThemes}}
<h3>{{i18n "admin.customize.theme.included_themes"}}</h3>
{{#unless model.childThemes.length}}
<p>
<label class='checkbox-label'>
{{input type="checkbox" checked=allowChildThemes}}
{{i18n "admin.customize.theme.child_themes_check"}}
</label>
</p>
{{else}}
<ul>
{{#each model.childThemes as |child|}}
<li>{{child.name}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit" icon="times"}}</li>
{{/each}}
</ul>
{{/unless}}
{{#if selectableChildThemes}}
<p>{{combo-box content=selectableChildThemes
nameProperty="name"
value=selectedChildThemeId
valueAttribute="id"}}
{{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
</p>
{{/if}}
{{/if}}
<a class="btn export" target="_blank" href={{downloadUrl}}>{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a>
{{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}}
</div>

View File

@ -0,0 +1,24 @@
{{#unless editingTheme}}
<div class='content-list span6'>
<h3>{{i18n 'admin.customize.theme.long_title'}}</h3>
<ul>
{{#each model as |theme|}}
<li>
{{#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}}
</li>
{{/each}}
</ul>
{{d-button label="admin.customize.new" icon="plus" action="newTheme" class="btn-primary"}}
{{d-button action="importModal" icon="upload" label="admin.customize.import"}}
</div>
{{/unless}}
{{outlet}}

View File

@ -1,7 +1,7 @@
<div class='customize'>
{{#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'}}

View File

@ -0,0 +1,12 @@
<div>
{{#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}}
<div class="modal-footer">
<button class='btn btn-primary' {{action "selectBase"}}>{{fa-icon 'plus'}}{{i18n 'admin.customize.new'}}</button>
</div>
</div>

View File

@ -0,0 +1,27 @@
{{#d-modal-body class='upload-selector' title="admin.customize.theme.import_theme"}}
<div class="radios">
{{radio-button name="upload" id="local" value="local" selection=selection}}
<label class="radio" for="local">{{i18n 'upload_selector.from_my_computer'}}</label>
{{#if local}}
<div class="inputs">
<input type="file" id="file-input" accept='.dcstyle.json'><br>
<span class="description">{{i18n 'admin.customize.theme.import_file_tip'}}</span>
</div>
{{/if}}
</div>
<div class="radios">
{{radio-button name="upload" id="remote" value="remote" selection=selection}}
<label class="radio" for="remote">{{i18n 'upload_selector.from_the_web'}}</label>
{{#if remote}}
<div class="inputs">
{{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}}
<span class="description">{{i18n 'admin.customize.theme.import_web_tip'}}</span>
</div>
{{/if}}
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action="importTheme" disabled=loading class='btn btn-primary' icon='upload' label='admin.customize.import'}}
<a href {{action "closeModal"}}>{{i18n 'cancel'}}</a>
</div>

View File

@ -0,0 +1,8 @@
<div>
{{#d-modal-body title="admin.logs.staff_actions.modal_title"}}
{{{diff}}}
{{/d-modal-body}}
<div class="modal-footer">
<button class='btn btn-primary' {{action "closeModal"}}>{{i18n 'close'}}</button>
</div>
</div>

View File

@ -1,29 +0,0 @@
<div>
<ul class="nav nav-pills">
<li class="{{if newSelected 'active'}}">
<a href {{action "selectNew"}}>{{i18n 'admin.logs.staff_actions.new_value'}}</a>
</li>
<li class="{{if previousSelected 'active'}}">
<a href {{action "selectPrevious"}}>{{i18n 'admin.logs.staff_actions.previous_value'}}</a>
</li>
</ul>
{{#d-modal-body title="admin.logs.staff_actions.modal_title"}}
<div class="modal-tab new-tab {{unless newSelected 'invisible'}}">
{{#if model.new_value}}
{{site-customization-change-details change=model.new_value}}
{{else}}
{{i18n 'admin.logs.staff_actions.deleted'}}
{{/if}}
</div>
<div class="modal-tab previous-tab {{unless previousSelected 'invisible'}}">
{{#if model.previous_value}}
{{site-customization-change-details change=model.previous_value}}
{{else}}
{{i18n 'admin.logs.staff_actions.no_previous'}}
{{/if}}
</div>
{{/d-modal-body}}
<div class="modal-footer">
<button class='btn btn-primary' {{action "closeModal"}}>{{i18n 'close'}}</button>
</div>
</div>

View File

@ -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(`<option ${selectedText} value="${val}">${name}</option>`);
});
}
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');
},

View File

@ -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) {

View File

@ -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);
}
});

View File

@ -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, '');
}
},

View File

@ -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);
},

View File

@ -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);
}
}
});

View File

@ -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);
}
});
}

View File

@ -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("<style>" + data + "</style>");
}
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);
}
});
}

View File

@ -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;
}

View File

@ -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;
});
},

View File

@ -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')
});
},

View File

@ -1 +1 @@
{{text-field class="hex-input" value=hexValue maxlength="6"}}
{{text-field class="hex-input" value=hexValue maxlength="6"}} <input class="picker" type="input"/>

View File

@ -1,12 +0,0 @@
<div class="jsfu-shade-container">
<div class="jsfu-file">
<input id="js-file-input" type="file" style="display:none;" accept={{accept}}>
{{d-button class="fileSelect" action="selectFile" class="" icon="upload" label="upload_selector.select_file"}}
{{conditional-loading-spinner condition=loading size="small"}}
</div>
<div class="jsfu-separator">{{i18n "alternation"}}</div>
<div class="jsfu-paste">
{{textarea value=value}}
</div>
<div class="jsfu-shade {{if hover '' 'hidden'}}"><span class="text">{{fa-icon "upload"}}</span></div>
</div>

View File

@ -354,6 +354,15 @@
</div>
{{/if}}
{{#if userSelectableThemes}}
<div class="control-group theme">
<label class="control-label">{{i18n 'user.theme'}}</label>
<div class="controls">
{{combo-box content=userSelectableThemes value=selectedTheme}}
</div>
</div>
{{/if}}
{{plugin-outlet name="user-custom-controls" args=(hash model=model)}}
<div class="control-group save-button">

View File

@ -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();
},

View File

@ -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');

View File

@ -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%); }

View File

@ -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;
}
}

View File

@ -1,5 +1,5 @@
@import "common/foundation/variables";
@import "common/foundation/mixins";
@import "./variables";
@import "./mixins";
// --------------------------------------------------
// Base styles for HTML elements

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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

View File

@ -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

View File

@ -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 = "<h2>#{CGI.escapeHTML(cur["name"].to_s)}</h2><p></p>"
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 << "<h3>#{k}</h3><p></p>"
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

20
app/models/child_theme.rb Normal file
View File

@ -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
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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 = <<PLUGIN_API_JS
Discourse._registerPluginCode('#{version}', api => {
#{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 <<COMPILED
<script>
(function() {
Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
})();
</script>
COMPILED
else
template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})"
node.replace <<COMPILED
<script>
(function() {
Ember.TEMPLATES[#{name.inspect}] = #{template};
})();
</script>
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("<script>#{code}</script>")
rescue MiniRacer::RuntimeError => ex
node.replace("<script type='text/discourse-js-error'>#{ex.message}</script>")
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{<link class="custom-css" rel="stylesheet" href="#{href}" type="text/css" media="all">}.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)
#

View File

@ -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
#

256
app/models/theme.rb Normal file
View File

@ -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
#

117
app/models/theme_field.rb Normal file
View File

@ -0,0 +1,117 @@
class ThemeField < ActiveRecord::Base
COMPILER_VERSION = 5
belongs_to :theme
def transpile(es6_source, version)
template = Tilt::ES6ModuleTranspilerTemplate.new {}
wrapped = <<PLUGIN_API_JS
Discourse._registerPluginCode('#{version}', api => {
#{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 <<COMPILED
<script>
(function() {
Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
})();
</script>
COMPILED
else
template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})"
node.replace <<COMPILED
<script>
(function() {
Ember.TEMPLATES[#{name.inspect}] = #{template};
})();
</script>
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("<script>#{code}</script>")
rescue MiniRacer::RuntimeError => ex
node.replace("<script type='text/discourse-js-error'>#{ex.message}</script>")
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
#

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More