From a3e8c3cd7ba5b8e0ea5d89e6eded3f995ab24aba Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 12 Apr 2017 10:52:52 -0400 Subject: [PATCH] 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 --- Gemfile | 4 +- Gemfile.lock | 17 +- .../javascripts/admin/adapters/theme.js.es6 | 20 + .../admin/components/ace-editor.js.es6 | 8 + .../admin/components/color-input.js.es6 | 33 +- .../admin/components/customize-link.js.es6 | 12 - .../components/inline-edit-checkbox.js.es6 | 36 ++ .../admin-customize-colors-show.js.es6 | 48 ++ .../controllers/admin-customize-colors.js.es6 | 106 +--- .../admin-customize-css-html-show.js.es6 | 78 --- .../admin-customize-themes-edit.js.es6 | 106 ++++ .../admin-customize-themes-show.js.es6 | 163 ++++++ .../admin-color-scheme-select-base.js.es6 | 14 + .../modals/admin-import-theme.js.es6 | 35 ++ .../modals/admin-theme-change.js.es6 | 13 + .../change-site-customization-details.js.es6 | 20 - .../delete-site-customization-details.js.es6 | 7 - .../admin/models/color-scheme.js.es6 | 34 +- .../admin/models/site-customization.js.es6 | 31 -- .../admin/models/staff-action-log.js.es6 | 2 +- .../javascripts/admin/models/theme.js.es6 | 94 ++++ .../routes/admin-customize-colors-show.js.es6 | 18 + .../routes/admin-customize-colors.js.es6 | 8 +- .../admin-customize-css-html-show.js.es6 | 11 - .../routes/admin-customize-css-html.js.es6 | 26 - .../admin/routes/admin-customize-index.js.es6 | 2 +- .../routes/admin-customize-themes-edit.js.es6 | 25 + .../routes/admin-customize-themes-show.js.es6 | 21 + .../routes/admin-customize-themes.js.es6 | 36 ++ .../admin-logs-staff-action-logs.js.es6 | 11 +- .../admin/routes/admin-route-map.js.es6 | 10 +- .../templates/components/customize-link.hbs | 5 - .../components/inline-edit-checkbox.hbs | 8 + .../templates/customize-colors-index.hbs | 1 + .../admin/templates/customize-colors-show.hbs | 53 ++ .../admin/templates/customize-colors.hbs | 72 +-- .../templates/customize-css-html-show.hbs | 75 --- .../admin/templates/customize-css-html.hbs | 13 - .../admin/templates/customize-themes-edit.hbs | 62 +++ ...l-index.hbs => customize-themes-index.hbs} | 0 .../admin/templates/customize-themes-show.hbs | 112 ++++ .../admin/templates/customize-themes.hbs | 24 + .../javascripts/admin/templates/customize.hbs | 2 +- .../modal/admin-color-scheme-select-base.hbs | 12 + .../templates/modal/admin-import-theme.hbs | 27 + .../templates/modal/admin-theme-change.hbs | 8 + .../modal/site-customization-change.hbs | 29 - .../components/combo-box.js.es6 | 32 +- .../discourse/adapters/rest.js.es6 | 30 +- .../components/json-file-uploader.js.es6 | 103 ---- .../components/search-advanced-options.js.es6 | 6 +- .../discourse/controllers/preferences.js.es6 | 14 +- .../controllers/upload-customization.js.es6 | 30 -- .../initializers/live-development.js.es6 | 53 +- .../discourse/lib/load-script.js.es6 | 11 +- .../discourse/lib/theme-selector.js.es6 | 62 +++ .../javascripts/discourse/models/store.js.es6 | 10 +- .../discourse/routes/preferences.js.es6 | 3 +- .../templates/components/color-input.hbs | 2 +- .../components/json-file-uploader.hbs | 12 - .../discourse/templates/preferences.hbs | 9 + .../wizard/components/theme-preview.js.es6 | 2 +- .../javascripts/wizard/models/wizard.js.es6 | 2 +- .../stylesheets/common/admin/admin_base.scss | 140 +---- .../stylesheets/common/admin/customize.scss | 157 ++++++ .../{badges.css.scss => badges.scss} | 0 .../{banner.css.scss => banner.scss} | 0 .../{buttons.css.scss => buttons.scss} | 0 ...{date-picker.css.scss => date-picker.scss} | 0 ...tcuts.css.scss => keyboard_shortcuts.scss} | 0 .../components/{navs.css.scss => navs.scss} | 0 .../stylesheets/common/foundation/base.scss | 4 +- .../common/foundation/variables.scss | 2 +- app/assets/stylesheets/desktop.scss | 2 +- .../{embed.css.scss => embed.scss} | 5 +- app/assets/stylesheets/mobile.scss | 2 +- .../{sweetalert.css => sweetalert.scss} | 0 .../admin/color_schemes_controller.rb | 4 +- .../admin/site_customizations_controller.rb | 92 ---- .../admin/staff_action_logs_controller.rb | 69 +++ app/controllers/admin/themes_controller.rb | 192 +++++++ app/controllers/application_controller.rb | 4 +- .../site_customizations_controller.rb | 35 -- app/controllers/stylesheets_controller.rb | 53 +- app/controllers/themes_controller.rb | 28 + app/helpers/application_helper.rb | 23 + app/models/category.rb | 3 +- app/models/child_theme.rb | 20 + app/models/color_scheme.rb | 114 ++-- app/models/remote_theme.rb | 85 +++ app/models/site_customization.rb | 299 ----------- app/models/stylesheet_cache.rb | 6 +- app/models/theme.rb | 256 +++++++++ app/models/theme_field.rb | 117 ++++ app/models/user_history.rb | 10 +- .../color_scheme_color_serializer.rb | 7 +- app/serializers/color_scheme_serializer.rb | 6 +- .../site_customization_serializer.rb | 7 - app/serializers/site_serializer.rb | 14 +- app/serializers/theme_serializer.rb | 42 ++ app/serializers/user_history_serializer.rb | 3 +- app/services/color_scheme_revisor.rb | 51 +- app/services/staff_action_logger.rb | 59 +- .../common/_discourse_stylesheet.html.erb | 10 +- app/views/layouts/application.html.erb | 7 +- app/views/layouts/crawler.html.erb | 16 +- app/views/layouts/embed.html.erb | 2 +- app/views/layouts/no_ember.html.erb | 12 +- app/views/wizard/index.html.erb | 2 +- config/application.rb | 13 +- config/environments/development.rb | 8 +- config/environments/production.rb | 2 - config/initializers/100-sprockets.rb | 19 - config/locales/client.en.yml | 90 +++- config/locales/server.en.yml | 2 + config/routes.rb | 19 +- config/site_settings.yml | 2 + db/migrate/20170313192741_add_themes.rb | 79 +++ ...322155537_add_theme_to_stylesheet_cache.rb | 6 + ...170324144456_amend_css_columns_in_theme.rb | 13 + .../20170328163918_break_up_themes_table.rb | 54 ++ ...22_add_compiler_version_to_theme_fields.rb | 5 + db/migrate/20170407154510_rename_theme_id.rb | 5 + .../20170410170923_add_theme_remote_fields.rb | 17 + lib/autospec/manager.rb | 12 +- lib/freedom_patches/resolve.rb | 19 - lib/git_importer.rb | 49 ++ lib/middleware/turbo_dev.rb | 1 + lib/sass/discourse_safe_sass_importer.rb | 32 -- lib/sass/discourse_sass_compiler.rb | 85 --- lib/sass/discourse_sass_importer.rb | 100 ---- lib/sass/discourse_stylesheets.rb | 178 ------ lib/stylesheet/common.rb | 5 + lib/stylesheet/compiler.rb | 60 +++ lib/stylesheet/functions.rb | 9 + lib/stylesheet/importer.rb | 126 +++++ lib/stylesheet/manager.rb | 270 ++++++++++ lib/stylesheet/watcher.rb | 70 +++ lib/tasks/assets.rake | 6 +- lib/wizard/builder.rb | 31 +- public/javascripts/spectrum.css | 507 ++++++++++++++++++ public/javascripts/spectrum.js | 2 + .../discourse_sass_compiler_spec.rb | 30 -- spec/components/discourse_stylesheets_spec.rb | 46 -- spec/components/step_updater_spec.rb | 14 +- spec/components/stylesheet/compiler_spec.rb | 21 + spec/components/stylesheet/manager_spec.rb | 57 ++ .../admin/color_schemes_controller_spec.rb | 1 - .../site_customizations_controller_spec.rb | 48 -- .../staff_action_logs_controller_spec.rb | 30 +- .../admin/themes_controller_spec.rb | 101 ++++ .../site_customizations_controller_spec.rb | 45 -- .../stylesheets_controller_spec.rb | 31 +- spec/fabricators/color_scheme_fabricator.rb | 1 - spec/models/color_scheme_spec.rb | 29 +- spec/models/remote_theme_spec.rb | 84 +++ spec/models/site_customization_spec.rb | 155 ------ spec/models/site_spec.rb | 38 ++ spec/models/stylesheet_cache_spec.rb | 6 +- spec/models/theme_spec.rb | 141 +++++ spec/services/color_scheme_revisor_spec.rb | 120 +---- spec/services/staff_action_logger_spec.rb | 46 +- test/stylesheets/test_helper.css | 6 +- 163 files changed, 4415 insertions(+), 2424 deletions(-) create mode 100644 app/assets/javascripts/admin/adapters/theme.js.es6 delete mode 100644 app/assets/javascripts/admin/components/customize-link.js.es6 create mode 100644 app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 delete mode 100644 app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 delete mode 100644 app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 delete mode 100644 app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 delete mode 100644 app/assets/javascripts/admin/models/site-customization.js.es6 create mode 100644 app/assets/javascripts/admin/models/theme.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 delete mode 100644 app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 delete mode 100644 app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 delete mode 100644 app/assets/javascripts/admin/templates/components/customize-link.hbs create mode 100644 app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-colors-index.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-colors-show.hbs delete mode 100644 app/assets/javascripts/admin/templates/customize-css-html-show.hbs delete mode 100644 app/assets/javascripts/admin/templates/customize-css-html.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-themes-edit.hbs rename app/assets/javascripts/admin/templates/{customize-css-html-index.hbs => customize-themes-index.hbs} (100%) create mode 100644 app/assets/javascripts/admin/templates/customize-themes-show.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-themes.hbs create mode 100644 app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs create mode 100644 app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs create mode 100644 app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs delete mode 100644 app/assets/javascripts/admin/templates/modal/site-customization-change.hbs delete mode 100644 app/assets/javascripts/discourse/components/json-file-uploader.js.es6 delete mode 100644 app/assets/javascripts/discourse/controllers/upload-customization.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/theme-selector.js.es6 delete mode 100644 app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs create mode 100644 app/assets/stylesheets/common/admin/customize.scss rename app/assets/stylesheets/common/components/{badges.css.scss => badges.scss} (100%) rename app/assets/stylesheets/common/components/{banner.css.scss => banner.scss} (100%) rename app/assets/stylesheets/common/components/{buttons.css.scss => buttons.scss} (100%) rename app/assets/stylesheets/common/components/{date-picker.css.scss => date-picker.scss} (100%) rename app/assets/stylesheets/common/components/{keyboard_shortcuts.css.scss => keyboard_shortcuts.scss} (100%) rename app/assets/stylesheets/common/components/{navs.css.scss => navs.scss} (100%) rename app/assets/stylesheets/{embed.css.scss => embed.scss} (97%) rename app/assets/stylesheets/vendor/{sweetalert.css => sweetalert.scss} (100%) mode change 100755 => 100644 delete mode 100644 app/controllers/admin/site_customizations_controller.rb create mode 100644 app/controllers/admin/themes_controller.rb delete mode 100644 app/controllers/site_customizations_controller.rb create mode 100644 app/controllers/themes_controller.rb create mode 100644 app/models/child_theme.rb create mode 100644 app/models/remote_theme.rb delete mode 100644 app/models/site_customization.rb create mode 100644 app/models/theme.rb create mode 100644 app/models/theme_field.rb delete mode 100644 app/serializers/site_customization_serializer.rb create mode 100644 app/serializers/theme_serializer.rb delete mode 100644 config/initializers/100-sprockets.rb create mode 100644 db/migrate/20170313192741_add_themes.rb create mode 100644 db/migrate/20170322155537_add_theme_to_stylesheet_cache.rb create mode 100644 db/migrate/20170324144456_amend_css_columns_in_theme.rb create mode 100644 db/migrate/20170328163918_break_up_themes_table.rb create mode 100644 db/migrate/20170328203122_add_compiler_version_to_theme_fields.rb create mode 100644 db/migrate/20170407154510_rename_theme_id.rb create mode 100644 db/migrate/20170410170923_add_theme_remote_fields.rb delete mode 100644 lib/freedom_patches/resolve.rb create mode 100644 lib/git_importer.rb delete mode 100644 lib/sass/discourse_safe_sass_importer.rb delete mode 100644 lib/sass/discourse_sass_compiler.rb delete mode 100644 lib/sass/discourse_sass_importer.rb delete mode 100644 lib/sass/discourse_stylesheets.rb create mode 100644 lib/stylesheet/common.rb create mode 100644 lib/stylesheet/compiler.rb create mode 100644 lib/stylesheet/functions.rb create mode 100644 lib/stylesheet/importer.rb create mode 100644 lib/stylesheet/manager.rb create mode 100644 lib/stylesheet/watcher.rb create mode 100644 public/javascripts/spectrum.css create mode 100644 public/javascripts/spectrum.js delete mode 100644 spec/components/discourse_sass_compiler_spec.rb delete mode 100644 spec/components/discourse_stylesheets_spec.rb create mode 100644 spec/components/stylesheet/compiler_spec.rb create mode 100644 spec/components/stylesheet/manager_spec.rb delete mode 100644 spec/controllers/admin/site_customizations_controller_spec.rb create mode 100644 spec/controllers/admin/themes_controller_spec.rb delete mode 100644 spec/controllers/site_customizations_controller_spec.rb create mode 100644 spec/models/remote_theme_spec.rb delete mode 100644 spec/models/site_customization_spec.rb create mode 100644 spec/models/theme_spec.rb diff --git a/Gemfile b/Gemfile index 6dc920d055a..57e86b78176 100644 --- a/Gemfile +++ b/Gemfile @@ -93,8 +93,6 @@ gem 'thor', require: false gem 'rest-client' gem 'rinku' gem 'sanitize' -gem 'sass' -gem 'sass-rails' gem 'sidekiq' gem 'sidekiq-statistic' @@ -181,3 +179,5 @@ gem 'memory_profiler', require: false, platform: :mri gem 'rmmseg-cpp', require: false gem 'logster' + +gem 'sassc', require: false diff --git a/Gemfile.lock b/Gemfile.lock index dcb353855ae..a0f8c1af3d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,7 +105,7 @@ GEM rake rake-compiler fast_xs (0.8.0) - ffi (1.9.17) + ffi (1.9.18) flamegraph (0.9.5) foreman (0.82.0) thor (~> 0.19.1) @@ -318,13 +318,11 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.2.19) - sass-rails (5.0.4) - railties (>= 4.0.0, < 5.0) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) + sass (3.4.23) + sassc (1.11.2) + bundler + ffi (~> 1.9.6) + sass (>= 3.3.0) seed-fu (2.3.5) activerecord (>= 3.1, < 4.3) activesupport (>= 3.1, < 4.3) @@ -462,8 +460,7 @@ DEPENDENCIES rtlit ruby-readability sanitize - sass - sass-rails + sassc seed-fu (~> 2.3.5) shoulda sidekiq diff --git a/app/assets/javascripts/admin/adapters/theme.js.es6 b/app/assets/javascripts/admin/adapters/theme.js.es6 new file mode 100644 index 00000000000..df9c8830d1a --- /dev/null +++ b/app/assets/javascripts/admin/adapters/theme.js.es6 @@ -0,0 +1,20 @@ +import RestAdapter from 'discourse/adapters/rest'; + +export default RestAdapter.extend({ + basePath() { + return "/admin/"; + }, + + afterFindAll(results) { + let map = {}; + results.forEach(theme => {map[theme.id] = theme;}); + results.forEach(theme => { + let mapped = theme.get("child_themes") || []; + mapped = mapped.map(t => map[t.id]); + theme.set("childThemes", mapped); + }); + return results; + }, + + jsonMode: true +}); diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6 index a03865c40ce..3c1d12ffe9c 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js.es6 +++ b/app/assets/javascripts/admin/components/ace-editor.js.es6 @@ -14,6 +14,13 @@ export default Ember.Component.extend({ } }, + @observes('mode') + modeChanged() { + if (this._editor && !this._skipContentChangeEvent) { + this._editor.getSession().setMode("ace/mode/" + this.get('mode')); + } + }, + _destroyEditor: function() { if (this._editor) { this._editor.destroy(); @@ -41,6 +48,7 @@ export default Ember.Component.extend({ editor.setTheme("ace/theme/chrome"); editor.setShowPrintMargin(false); + editor.setOptions({fontSize: "14px"}); editor.getSession().setMode("ace/mode/" + this.get('mode')); editor.on('change', () => { this._skipContentChangeEvent = true; diff --git a/app/assets/javascripts/admin/components/color-input.js.es6 b/app/assets/javascripts/admin/components/color-input.js.es6 index 98d5f6e6bbd..005c4f5d4ba 100644 --- a/app/assets/javascripts/admin/components/color-input.js.es6 +++ b/app/assets/javascripts/admin/components/color-input.js.es6 @@ -1,3 +1,5 @@ +import {default as loadScript, loadCSS } from 'discourse/lib/load-script'; + /** An input field for a color. @@ -6,19 +8,36 @@ @params valid is a boolean indicating if the input field is a valid color. **/ export default Ember.Component.extend({ + classNames: ['color-picker'], hexValueChanged: function() { var hex = this.get('hexValue'); + let $text = this.$('input.hex-input'); + if (this.get('valid')) { - this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';'); + $text.attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';'); + + if (this.get('pickerLoaded')) { + this.$('.picker').spectrum({color: "#" + this.get('hexValue')}); + } } else { - this.$('input').attr('style', ''); + $text.attr('style', ''); } }.observes('hexValue', 'brightnessValue', 'valid'), - _triggerHexChanged: function() { - var self = this; - Em.run.schedule('afterRender', function() { - self.hexValueChanged(); + didInsertElement() { + loadScript('/javascripts/spectrum.js').then(()=>{ + loadCSS('/javascripts/spectrum.css').then(()=>{ + Em.run.schedule('afterRender', ()=>{ + this.$('.picker').spectrum({color: "#" + this.get('hexValue')}) + .on("change.spectrum", (me, color)=>{ + this.set('hexValue', color.toHexString().replace("#","")); + }); + this.set('pickerLoaded', true); + }); + }); }); - }.on('didInsertElement') + Em.run.schedule('afterRender', ()=>{ + this.hexValueChanged(); + }); + } }); diff --git a/app/assets/javascripts/admin/components/customize-link.js.es6 b/app/assets/javascripts/admin/components/customize-link.js.es6 deleted file mode 100644 index 0600f6b5cdb..00000000000 --- a/app/assets/javascripts/admin/components/customize-link.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -import { getOwner } from 'discourse-common/lib/get-owner'; - -export default Ember.Component.extend({ - router: function() { - return getOwner(this).lookup('router:main'); - }.property(), - - active: function() { - const id = this.get('customization.id'); - return this.get('router.url').indexOf(`/customize/css_html/${id}/css`) !== -1; - }.property('router.url', 'customization.id') -}); diff --git a/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 new file mode 100644 index 00000000000..5c168760c7f --- /dev/null +++ b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 @@ -0,0 +1,36 @@ +import {default as computed, observes} from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + init(){ + this._super(); + this.set("checkedInternal", this.get("checked")); + }, + + classNames: ['inline-edit'], + + @observes("checked") + checkedChanged() { + this.set("checkedInternal", this.get("checked")); + }, + + @computed("labelKey") + label(key) { + return I18n.t(key); + }, + + @computed("checked", "checkedInternal") + changed(checked, checkedInternal) { + return (!!checked) !== (!!checkedInternal); + }, + + actions: { + cancelled(){ + this.set("checkedInternal", this.get("checked")); + }, + + finished(){ + this.set("checked", this.get("checkedInternal")); + this.sendAction(); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 new file mode 100644 index 00000000000..e57606a489c --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 @@ -0,0 +1,48 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + @computed("model.colors","onlyOverridden") + colors(allColors, onlyOverridden) { + if (onlyOverridden) { + return allColors.filter(color => color.get("overridden")); + } else { + return allColors; + } + }, + + actions: { + + revert: function(color) { + color.revert(); + }, + + undo: function(color) { + color.undo(); + }, + + copy() { + var newColorScheme = Em.copy(this.get('model'), true); + newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + this.get('model.name')); + newColorScheme.save().then(()=>{ + this.get('allColors').pushObject(newColorScheme); + this.replaceRoute('adminCustomize.colors.show', newColorScheme); + }); + }, + + save: function() { + this.get('model').save(); + }, + + destroy: function() { + + return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { + if (result) { + this.get('model').destroy().then(()=>{ + this.get('allColors').removeObject(this.get('model')); + this.replaceRoute('adminCustomize.colors'); + }); + } + }); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 index ae253aec843..87166e386f5 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 @@ -1,10 +1,14 @@ -export default Ember.Controller.extend({ - onlyOverridden: false, +import showModal from 'discourse/lib/show-modal'; +export default Ember.Controller.extend({ baseColorScheme: function() { return this.get('model').findBy('is_base', true); }.property('model.@each.id'), + baseColorSchemes: function() { + return this.get('model').filterBy('is_base', true); + }.property('model.@each.id'), + baseColors: function() { var baseColorsHash = Em.Object.create({}); _.each(this.get('baseColorScheme.colors'), function(color){ @@ -13,99 +17,25 @@ export default Ember.Controller.extend({ return baseColorsHash; }.property('baseColorScheme'), - removeSelected() { - this.get('model').removeObject(this.get('selectedItem')); - this.set('selectedItem', null); - }, - - filterContent: function() { - if (!this.get('selectedItem')) { return; } - - if (!this.get('onlyOverridden')) { - this.set('colors', this.get('selectedItem.colors')); - return; - } - - const matches = []; - _.each(this.get('selectedItem.colors'), function(color){ - if (color.get('overridden')) matches.pushObject(color); - }); - - this.set('colors', matches); - }.observes('onlyOverridden'), - - updateEnabled: function() { - var selectedItem = this.get('selectedItem'); - if (selectedItem.get('enabled')) { - this.get('model').forEach(function(c) { - if (c !== selectedItem) { - c.set('enabled', false); - c.startTrackingChanges(); - c.notifyPropertyChange('description'); - } - }); - } - }, - actions: { - selectColorScheme: function(colorScheme) { - if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); } - this.set('selectedItem', colorScheme); - this.set('colors', colorScheme.get('colors')); - colorScheme.set('savingStatus', null); - colorScheme.set('selected', true); - this.filterContent(); + + newColorSchemeWithBase(baseKey) { + const base = this.get('baseColorSchemes').findBy('base_scheme_id', baseKey); + const newColorScheme = Em.copy(base, true); + newColorScheme.set('name', I18n.t('admin.customize.colors.new_name')); + newColorScheme.set('base_scheme_id', base.get('base_scheme_id')); + newColorScheme.save().then(()=>{ + this.get('model').pushObject(newColorScheme); + newColorScheme.set('savingStatus', null); + this.replaceRoute('adminCustomize.colors.show', newColorScheme); + }); }, newColorScheme() { - const newColorScheme = Em.copy(this.get('baseColorScheme'), true); - newColorScheme.set('name', I18n.t('admin.customize.colors.new_name')); - this.get('model').pushObject(newColorScheme); - this.send('selectColorScheme', newColorScheme); - this.set('onlyOverridden', false); + showModal('admin-color-scheme-select-base', { model: this.get('baseColorSchemes'), admin: true}); }, - revert: function(color) { - color.revert(); - }, - undo: function(color) { - color.undo(); - }, - - toggleEnabled: function() { - var selectedItem = this.get('selectedItem'); - selectedItem.toggleProperty('enabled'); - selectedItem.save({enabledOnly: true}); - this.updateEnabled(); - }, - - save: function() { - this.get('selectedItem').save(); - this.updateEnabled(); - }, - - copy(colorScheme) { - var newColorScheme = Em.copy(colorScheme, true); - newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name')); - this.get('model').pushObject(newColorScheme); - this.send('selectColorScheme', newColorScheme); - }, - - destroy: function() { - var self = this, - item = self.get('selectedItem'); - - return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { - if (result) { - if (item.get('newRecord')) { - self.removeSelected(); - } else { - item.destroy().then(function(){ self.removeSelected(); }); - } - } - }); - } } }); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 deleted file mode 100644 index 47cf280ae6c..00000000000 --- a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 +++ /dev/null @@ -1,78 +0,0 @@ -import { url } from 'discourse/lib/computed'; - -const sections = ['css', 'header', 'top', 'footer', 'head-tag', 'body-tag', - 'mobile-css', 'mobile-header', 'mobile-top', 'mobile-footer', - 'embedded-css']; - -const activeSections = {}; -sections.forEach(function(s) { - activeSections[Ember.String.camelize(s) + "Active"] = Ember.computed.equal('section', s); -}); - - -export default Ember.Controller.extend(activeSections, { - maximized: false, - section: null, - - previewUrl: url("model.key", "/?preview-style=%@"), - downloadUrl: url('model.id', '/admin/site_customizations/%@'), - - mobile: function() { - return this.get('section').indexOf('mobile-') === 0; - }.property('section'), - - maximizeIcon: function() { - return this.get('maximized') ? 'compress' : 'expand'; - }.property('maximized'), - - saveButtonText: function() { - return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save'); - }.property('model.isSaving'), - - saveDisabled: function() { - return !this.get('model.changed') || this.get('model.isSaving'); - }.property('model.changed', 'model.isSaving'), - - adminCustomizeCssHtml: Ember.inject.controller(), - - undoPreviewUrl: url('/?preview-style='), - defaultStyleUrl: url('/?preview-style=default'), - - actions: { - save() { - this.get('model').saveChanges(); - }, - - destroy() { - return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { - if (result) { - const model = this.get('model'); - model.destroyRecord().then(() => { - this.get('adminCustomizeCssHtml').get('model').removeObject(model); - this.transitionToRoute('adminCustomizeCssHtml'); - }); - } - }); - }, - - toggleMaximize: function() { - this.toggleProperty('maximized'); - }, - - toggleMobile: function() { - const section = this.get('section'); - - // Try to send to the same tab as before - let dest; - if (this.get('mobile')) { - dest = section.replace('mobile-', ''); - if (sections.indexOf(dest) === -1) { dest = 'css'; } - } else { - dest = 'mobile-' + section; - if (sections.indexOf(dest) === -1) { dest = 'mobile-css'; } - } - this.replaceRoute('adminCustomizeCssHtml.show', this.get('model.id'), dest); - } - } - -}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 new file mode 100644 index 00000000000..507ea7136ba --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -0,0 +1,106 @@ +import { url } from 'discourse/lib/computed'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + maximized: false, + section: null, + + targets: [ + {id: 0, name: I18n.t('admin.customize.theme.common')}, + {id: 1, name: I18n.t('admin.customize.theme.desktop')}, + {id: 2, name: I18n.t('admin.customize.theme.mobile')} + ], + + currentTarget: 0, + + setTargetName: function(name) { + let target; + switch(name) { + case "common": target = 0; break; + case "desktop": target = 1; break; + case "mobile": target = 2; break; + } + + this.set("currentTarget", target); + }, + + @computed("currentTarget") + currentTargetName(target) { + switch(parseInt(target)) { + case 0: return "common"; + case 1: return "desktop"; + case 2: return "mobile"; + } + }, + + @computed("fieldName") + activeSectionMode(fieldName) { + return fieldName && fieldName.indexOf("scss") > -1 ? "css" : "html"; + }, + + @computed("fieldName", "currentTargetName", "model") + activeSection: { + get(fieldName, target, model) { + return model.getField(target, fieldName); + }, + set(value, fieldName, target, model) { + model.setField(target, fieldName, value); + return value; + } + }, + + + @computed("currentTarget") + fields(target) { + let fields = [ + "scss", "head_tag", "header", "after_header", "body_tag", "footer" + ]; + + if (parseInt(target) === 0) { + fields.push("embedded_scss"); + } + + return fields.map(name=>{ + let hash = { + key: (`admin.customize.theme.${name}.text`), + name: name + }; + + if (name.indexOf("_tag") > 0) { + hash.icon = "file-text-o"; + } + + hash.title = I18n.t(`admin.customize.theme.${name}.title`); + + return hash; + }); + }, + + previewUrl: url('model.key', '/?preview-style=%@'), + + maximizeIcon: function() { + return this.get('maximized') ? 'compress' : 'expand'; + }.property('maximized'), + + saveButtonText: function() { + return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save'); + }.property('model.isSaving'), + + saveDisabled: function() { + return !this.get('model.changed') || this.get('model.isSaving'); + }.property('model.changed', 'model.isSaving'), + + undoPreviewUrl: url('/?preview-style='), + defaultStyleUrl: url('/?preview-style=default'), + + actions: { + save() { + this.get('model').saveChanges("theme_fields"); + }, + + toggleMaximize: function() { + this.toggleProperty('maximized'); + } + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 new file mode 100644 index 00000000000..7ca35012419 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -0,0 +1,163 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import { url } from 'discourse/lib/computed'; + +export default Ember.Controller.extend({ + + @computed("model.theme_fields.@each") + hasEditedFields(fields) { + return fields.any(f=>!Em.isBlank(f.value)); + }, + + @computed('model.theme_fields.@each') + editedDescriptions(fields) { + let descriptions = []; + let description = target => { + let current = fields.filter(field => field.target === target && !Em.isBlank(field.value)); + if (current.length > 0) { + let text = I18n.t('admin.customize.theme.'+target); + let localized = current.map(f=>I18n.t('admin.customize.theme.'+f.name + '.text')); + return text + ": " + localized.join(" , "); + } + }; + ['common','desktop','mobile'].forEach(target=> { + descriptions.push(description(target)); + }); + return descriptions.reject(d=>Em.isBlank(d)); + }, + + @computed("colorSchemeId", "model.color_scheme_id") + colorSchemeChanged(colorSchemeId, existingId) { + colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId); + return colorSchemeId !== existingId; + }, + + @computed("availableChildThemes", "model.childThemes.@each", "model", "allowChildThemes") + selectableChildThemes(available, childThemes, model, allowChildThemes) { + if (!allowChildThemes && (!childThemes || childThemes.length === 0)) { + return null; + } + + let themes = []; + available.forEach(t=> { + if (!childThemes || (childThemes.indexOf(t) === -1)) { + themes.push(t); + }; + }); + return themes.length === 0 ? null : themes; + }, + + showSchemes: Em.computed.or("model.default", "model.user_selectable"), + + @computed("allThemes", "allThemes.length", "model") + availableChildThemes(allThemes, count) { + if (count === 1) { + return null; + } + + let excludeIds = [this.get("model.id")]; + + let themes = []; + allThemes.forEach(theme => { + if (excludeIds.indexOf(theme.get("id")) === -1) { + themes.push(theme); + } + }); + + return themes; + }, + + downloadUrl: url('model.id', '/admin/themes/%@'), + + actions: { + + updateToLatest() { + this.set("updatingRemote", true); + this.get("model").updateToLatest().finally(()=>{ + this.set("updatingRemote", false); + }); + }, + + checkForThemeUpdates() { + this.set("updatingRemote", true); + this.get("model").checkForUpdates().finally(()=>{ + this.set("updatingRemote", false); + }); + }, + + cancelChangeScheme() { + this.set("colorSchemeId", this.get("model.color_scheme_id")); + }, + changeScheme(){ + let schemeId = this.get("colorSchemeId"); + this.set("model.color_scheme_id", schemeId === null ? null : parseInt(schemeId)); + this.get("model").saveChanges("color_scheme_id"); + }, + startEditingName() { + this.set("oldName", this.get("model.name")); + this.set("editingName", true); + }, + cancelEditingName() { + this.set("model.name", this.get("oldName")); + this.set("editingName", false); + }, + finishedEditingName() { + this.get("model").saveChanges("name"); + this.set("editingName", false); + }, + + editTheme() { + let edit = ()=>this.transitionToRoute('adminCustomizeThemes.edit', {model: this.get('model')}); + + if (this.get("model.remote_theme")) { + bootbox.confirm(I18n.t("admin.customize.theme.edit_confirm"), result => { + if (result) { + edit(); + } + }); + } else { + edit(); + } + }, + + applyDefault() { + const model = this.get("model"); + model.saveChanges("default").then(()=>{ + if (model.get("default")) { + this.get("allThemes").forEach(theme=>{ + if (theme !== model && theme.get('default')) { + theme.set("default", false); + } + }); + } + }); + }, + + applyUserSelectable() { + this.get("model").saveChanges("user_selectable"); + }, + + addChildTheme() { + let themeId = parseInt(this.get("selectedChildThemeId")); + let theme = this.get("allThemes").findBy("id", themeId); + this.get("model").addChildTheme(theme); + }, + + removeChildTheme(theme) { + this.get("model").removeChildTheme(theme); + }, + + destroy() { + return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { + if (result) { + const model = this.get('model'); + model.destroyRecord().then(() => { + this.get('allThemes').removeObject(model); + this.transitionToRoute('adminCustomizeThemes'); + }); + } + }); + }, + + } + +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 new file mode 100644 index 00000000000..94939fa09f9 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 @@ -0,0 +1,14 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; + +export default Ember.Controller.extend(ModalFunctionality, { + + adminCustomizeColors: Ember.inject.controller(), + + actions: { + selectBase() { + this.get('adminCustomizeColors') + .send('newColorSchemeWithBase', this.get('selectedBaseThemeId')); + this.send('closeModal'); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 new file mode 100644 index 00000000000..d59d419ef53 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 @@ -0,0 +1,35 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; +// import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend(ModalFunctionality, { + local: Ember.computed.equal('selection', 'local'), + remote: Ember.computed.equal('selection', 'remote'), + selection: 'local', + adminCustomizeThemes: Ember.inject.controller(), + + actions: { + importTheme() { + + let options = { + type: 'POST' + }; + + if (this.get('local')) { + options.processData = false; + options.contentType = false; + options.data = new FormData(); + options.data.append('theme', $('#file-input')[0].files[0]); + } else { + options.data = {remote: this.get('uploadUrl')}; + } + + ajax('/admin/themes/import', options).then(result=>{ + const theme = this.store.createRecord('theme',result.theme); + this.get('adminCustomizeThemes').send('addTheme', theme); + this.send('closeModal'); + }); + + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 new file mode 100644 index 00000000000..82aba506a2c --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 @@ -0,0 +1,13 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; + +export default Ember.Controller.extend(ModalFunctionality, { + loadDiff() { + this.set('loading', true); + ajax('/admin/logs/staff_action_logs/' + this.get('model.id') + '/diff') + .then(diff=>{ + this.set('loading', false); + this.set('diff', diff.side_by_side); + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 deleted file mode 100644 index ca6ac31db1f..00000000000 --- a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -export default Ember.Controller.extend(ModalFunctionality, { - previousSelected: Ember.computed.equal('selectedTab', 'previous'), - newSelected: Ember.computed.equal('selectedTab', 'new'), - - onShow: function() { - this.send("selectNew"); - }, - - actions: { - selectNew: function() { - this.set('selectedTab', 'new'); - }, - - selectPrevious: function() { - this.set('selectedTab', 'previous'); - } - } -}); diff --git a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 deleted file mode 100644 index 95537e305a3..00000000000 --- a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details"; - -export default ChangeSiteCustomizationDetailsController.extend({ - onShow() { - this.send("selectPrevious"); - } -}); diff --git a/app/assets/javascripts/admin/models/color-scheme.js.es6 b/app/assets/javascripts/admin/models/color-scheme.js.es6 index 743c779d6c4..20bb348d570 100644 --- a/app/assets/javascripts/admin/models/color-scheme.js.es6 +++ b/app/assets/javascripts/admin/models/color-scheme.js.es6 @@ -9,18 +9,17 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { }, description: function() { - return "" + this.name + (this.enabled ? ' (*)' : ''); + return "" + this.name; }.property(), startTrackingChanges: function() { this.set('originals', { - name: this.get('name'), - enabled: this.get('enabled') + name: this.get('name') }); }, copy: function() { - var newScheme = ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()}); + var newScheme = ColorScheme.create({name: this.get('name'), can_edit: true, colors: Em.A()}); _.each(this.get('colors'), function(c){ newScheme.colors.pushObject(ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), default_hex: c.get('default_hex')})); }); @@ -29,19 +28,15 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { changed: function() { if (!this.originals) return false; - if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true; + if (this.originals['name'] !== this.get('name')) return true; if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true; return false; - }.property('name', 'enabled', 'colors.@each.changed', 'saving'), + }.property('name', 'colors.@each.changed', 'saving'), disableSave: function() { return !this.get('changed') || this.get('saving') || _.any(this.get('colors'), function(c) { return !c.get('valid'); }); }.property('changed'), - disableEnable: function() { - return !this.get('id') || this.get('saving'); - }.property('id', 'saving'), - newRecord: function() { return (!this.get('id')); }.property('id'), @@ -53,11 +48,11 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { this.set('savingStatus', I18n.t('saving')); this.set('saving',true); - var data = { enabled: this.enabled }; + var data = {}; if (!opts || !opts.enabledOnly) { data.name = this.name; - + data.base_scheme_id = this.get('base_scheme_id'); data.colors = []; _.each(this.get('colors'), function(c) { if (!self.id || c.get('changed')) { @@ -78,8 +73,6 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { _.each(self.get('colors'), function(c) { c.startTrackingChanges(); }); - } else { - self.set('originals.enabled', data.enabled); } self.set('savingStatus', I18n.t('saved')); self.set('saving', false); @@ -96,30 +89,23 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { }); var ColorSchemes = Ember.ArrayProxy.extend({ - selectedItemChanged: function() { - var selected = this.get('selectedItem'); - _.each(this.get('content'),function(i) { - return i.set('selected', selected === i); - }); - }.observes('selectedItem') }); ColorScheme.reopenClass({ findAll: function() { var colorSchemes = ColorSchemes.create({ content: [], loading: true }); - ajax('/admin/color_schemes').then(function(all) { + return ajax('/admin/color_schemes').then(function(all) { _.each(all, function(colorScheme){ colorSchemes.pushObject(ColorScheme.create({ id: colorScheme.id, name: colorScheme.name, - enabled: colorScheme.enabled, is_base: colorScheme.is_base, + base_scheme_id: colorScheme.base_scheme_id, colors: colorScheme.colors.map(function(c) { return ColorSchemeColor.create({name: c.name, hex: c.hex, default_hex: c.default_hex}); }) })); }); - colorSchemes.set('loading', false); + return colorSchemes; }); - return colorSchemes; } }); diff --git a/app/assets/javascripts/admin/models/site-customization.js.es6 b/app/assets/javascripts/admin/models/site-customization.js.es6 deleted file mode 100644 index fe2176bf112..00000000000 --- a/app/assets/javascripts/admin/models/site-customization.js.es6 +++ /dev/null @@ -1,31 +0,0 @@ -import RestModel from 'discourse/models/rest'; - -const trackedProperties = [ - 'enabled', 'name', 'stylesheet', 'header', 'top', 'footer', 'mobile_stylesheet', - 'mobile_header', 'mobile_top', 'mobile_footer', 'head_tag', 'body_tag', 'embedded_css' -]; - -function changed() { - const originals = this.get('originals'); - if (!originals) { return false; } - return _.some(trackedProperties, (p) => originals[p] !== this.get(p)); -} - -const SiteCustomization = RestModel.extend({ - description: function() { - return "" + this.name + (this.enabled ? ' (*)' : ''); - }.property('selected', 'name', 'enabled'), - - changed: changed.property.apply(changed, trackedProperties.concat('originals')), - - startTrackingChanges: function() { - this.set('originals', this.getProperties(trackedProperties)); - }.on('init'), - - saveChanges() { - return this.save(this.getProperties(trackedProperties)).then(() => this.startTrackingChanges()); - }, - -}); - -export default SiteCustomization; diff --git a/app/assets/javascripts/admin/models/staff-action-log.js.es6 b/app/assets/javascripts/admin/models/staff-action-log.js.es6 index 24ec4fbcc08..f108dd21d8b 100644 --- a/app/assets/javascripts/admin/models/staff-action-log.js.es6 +++ b/app/assets/javascripts/admin/models/staff-action-log.js.es6 @@ -39,7 +39,7 @@ const StaffActionLog = Discourse.Model.extend({ }.property('action_name'), useCustomModalForDetails: function() { - return _.contains(['change_site_customization', 'delete_site_customization'], this.get('action_name')); + return _.contains(['change_theme', 'delete_theme'], this.get('action_name')); }.property('action_name') }); diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 new file mode 100644 index 00000000000..463d54b2586 --- /dev/null +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -0,0 +1,94 @@ +import RestModel from 'discourse/models/rest'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +const Theme = RestModel.extend({ + + @computed('theme_fields') + themeFields(fields) { + + if (!fields) { + this.set('theme_fields', []); + return {}; + } + + let hash = {}; + if (fields) { + fields.forEach(field=>{ + hash[field.target + " " + field.name] = field; + }); + } + return hash; + }, + + getField(target, name) { + let themeFields = this.get("themeFields"); + let key = target + " " + name; + let field = themeFields[key]; + return field ? field.value : ""; + }, + + setField(target, name, value) { + this.set("changed", true); + + let themeFields = this.get("themeFields"); + let key = target + " " + name; + let field = themeFields[key]; + if (!field) { + field = {name, target, value}; + this.theme_fields.push(field); + themeFields[key] = field; + } else { + field.value = value; + } + }, + + @computed("childThemes.@each") + child_theme_ids(childThemes) { + if (childThemes) { + return childThemes.map(theme => Ember.get(theme, "id")); + } + }, + + removeChildTheme(theme) { + const childThemes = this.get("childThemes"); + childThemes.removeObject(theme); + return this.saveChanges("child_theme_ids"); + }, + + addChildTheme(theme){ + let childThemes = this.get("childThemes"); + childThemes.removeObject(theme); + childThemes.pushObject(theme); + return this.saveChanges("child_theme_ids"); + }, + + @computed('name', 'default') + description: function(name, isDefault) { + if (isDefault) { + return I18n.t('admin.customize.theme.default_name', {name: name}); + } else { + return name; + } + }, + + checkForUpdates() { + return this.save({remote_check: true}) + .then(() => this.set("changed", false)); + }, + + updateToLatest() { + return this.save({remote_update: true}) + .then(() => this.set("changed", false)); + }, + + changed: false, + + saveChanges() { + const hash = this.getProperties.apply(this, arguments); + return this.save(hash) + .then(() => this.set("changed", false)); + }, + +}); + +export default Theme; diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 new file mode 100644 index 00000000000..3f8bdcddcd5 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 @@ -0,0 +1,18 @@ +export default Ember.Route.extend({ + + model(params) { + const all = this.modelFor('adminCustomize.colors'); + const model = all.findBy('id', parseInt(params.scheme_id)); + return model ? model : this.replaceWith('adminCustomize.colors.index'); + }, + + serialize(model) { + return {scheme_id: model.get('id')}; + }, + + setupController(controller, model) { + controller.set('model', model); + controller.set('allColors', this.modelFor('adminCustomize.colors')); + } +}); + diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 index 8a47f1ba212..043e571271b 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 @@ -6,9 +6,7 @@ export default Ember.Route.extend({ return ColorScheme.findAll(); }, - deactivate() { - this._super(); - this.controllerFor('adminCustomizeColors').set('selectedItem', null); - }, - + setupController(controller, model) { + controller.set("model", model); + } }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 deleted file mode 100644 index 7df829706fd..00000000000 --- a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 +++ /dev/null @@ -1,11 +0,0 @@ -export default Ember.Route.extend({ - model(params) { - const all = this.modelFor('adminCustomizeCssHtml'); - const model = all.findBy('id', parseInt(params.site_customization_id)); - return model ? { model, section: params.section } : this.replaceWith('adminCustomizeCssHtml.index'); - }, - - setupController(controller, hash) { - controller.setProperties(hash); - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 deleted file mode 100644 index 5bbb4609596..00000000000 --- a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 +++ /dev/null @@ -1,26 +0,0 @@ -import showModal from 'discourse/lib/show-modal'; -import { popupAjaxError } from 'discourse/lib/ajax-error'; - -export default Ember.Route.extend({ - model() { - return this.store.findAll('site-customization'); - }, - - actions: { - importModal() { - showModal('upload-customization'); - }, - - newCustomization(obj) { - obj = obj || {name: I18n.t("admin.customize.new_style")}; - const item = this.store.createRecord('site-customization'); - - const all = this.modelFor('adminCustomizeCssHtml'); - const self = this; - item.save(obj).then(function() { - all.pushObject(item); - self.transitionTo('adminCustomizeCssHtml.show', item.get('id'), 'css'); - }).catch(popupAjaxError); - } - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 index 45cb6e21fbe..d8b1446dfb6 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 @@ -1,5 +1,5 @@ export default Ember.Route.extend({ beforeModel() { - this.transitionTo('adminCustomize.colors'); + this.transitionTo('adminCustomizeThemes'); } }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 new file mode 100644 index 00000000000..aed0709e556 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 @@ -0,0 +1,25 @@ +export default Ember.Route.extend({ + model(params) { + const all = this.modelFor('adminCustomizeThemes'); + const model = all.findBy('id', parseInt(params.theme_id)); + return model ? { model, target: params.target, field_name: params.field_name} : this.replaceWith('adminCustomizeThemes.index'); + }, + + serialize(wrapper) { + return { + model: wrapper.model, + target: wrapper.target || "common", + field_name: wrapper.field_name || "scss", + theme_id: wrapper.model.get("id") + }; + }, + + + setupController(controller, wrapper) { + controller.set("model", wrapper.model); + controller.setTargetName(wrapper.target || "common"); + controller.set("fieldName", wrapper.field_name || "scss"); + this.controllerFor("adminCustomizeThemes").set("editingTheme", true); + }, + +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 new file mode 100644 index 00000000000..8e925ba6dd5 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 @@ -0,0 +1,21 @@ +export default Ember.Route.extend({ + + serialize(model) { + return {theme_id: model.get('id')}; + }, + + model(params) { + const all = this.modelFor('adminCustomizeThemes'); + const model = all.findBy('id', parseInt(params.theme_id)); + return model ? model : this.replaceWith('adminCustomizeTheme.index'); + }, + + setupController(controller, model) { + controller.set("model", model); + const parentController = this.controllerFor("adminCustomizeThemes"); + parentController.set("editingTheme", false); + controller.set("allThemes", parentController.get("model")); + controller.set("colorSchemes", parentController.get("model.extras.color_schemes")); + controller.set("colorSchemeId", model.get("color_scheme_id")); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 new file mode 100644 index 00000000000..6e5e19b7f51 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 @@ -0,0 +1,36 @@ +import showModal from 'discourse/lib/show-modal'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Route.extend({ + model() { + return this.store.findAll('theme'); + }, + + setupController(controller, model) { + this._super(controller, model); + // TODO ColorScheme to model + controller.set("editingTheme", false); + }, + + actions: { + importModal() { + showModal('admin-import-theme', {admin: true}); + }, + + addTheme(theme) { + const all = this.modelFor('adminCustomizeThemes'); + all.pushObject(theme); + this.transitionTo('adminCustomizeThemes.show', theme.get('id')); + }, + + + newTheme(obj) { + obj = obj || {name: I18n.t("admin.customize.new_style")}; + const item = this.store.createRecord('theme'); + + item.save(obj).then(() => { + this.send('addTheme', item); + }).catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 index 8b19ccb0dcf..698f90d77c1 100644 --- a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 @@ -13,14 +13,9 @@ export default Discourse.Route.extend({ }, showCustomDetailsModal(model) { - const modalName = (model.action_name + '_details').replace(/\_/g, "-"); - - showModal(modalName, { - model, - admin: true, - templateName: 'site-customization-change' - }); - this.controllerFor('modal').set('modalClass', 'tabbed-modal log-details-modal'); + let modal = showModal('admin-theme-change', { model, admin: true}); + this.controllerFor('modal').set('modalClass', 'history-modal'); + modal.loadDiff(); } } }); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index bd38784bb7f..dd87207156a 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -15,10 +15,14 @@ export default function() { }); this.route('adminCustomize', { path: '/customize', resetNamespace: true } ,function() { - this.route('colors'); - this.route('adminCustomizeCssHtml', { path: 'css_html', resetNamespace: true }, function() { - this.route('show', {path: '/:site_customization_id/:section'}); + this.route('colors', function() { + this.route('show', {path: '/:scheme_id'}); + }); + + this.route('adminCustomizeThemes', { path: 'themes', resetNamespace: true }, function() { + this.route('show', {path: '/:theme_id'}); + this.route('edit', {path: '/:theme_id/:target/:field_name/edit'}); }); this.route('adminSiteText', { path: '/site_texts', resetNamespace: true }, function() { diff --git a/app/assets/javascripts/admin/templates/components/customize-link.hbs b/app/assets/javascripts/admin/templates/components/customize-link.hbs deleted file mode 100644 index dd3c4104c75..00000000000 --- a/app/assets/javascripts/admin/templates/components/customize-link.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
  • - - {{customization.description}} - -
  • diff --git a/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs new file mode 100644 index 00000000000..3a651ad0dfe --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs @@ -0,0 +1,8 @@ + +{{#if changed}} + {{d-button action="finished" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelled" class="btn-small cancel-edit" icon="times"}} +{{/if}} diff --git a/app/assets/javascripts/admin/templates/customize-colors-index.hbs b/app/assets/javascripts/admin/templates/customize-colors-index.hbs new file mode 100644 index 00000000000..62bbb7a8fc3 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-colors-index.hbs @@ -0,0 +1 @@ +

    {{i18n 'admin.customize.colors.about'}}

    diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs new file mode 100644 index 00000000000..3c57058c9a3 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs @@ -0,0 +1,53 @@ +
    +
    +

    {{text-field class="style-name" value=model.name}}

    + +
    + + + + {{model.savingStatus}} +
    + +
    + +
    + +
    + + {{#if colors.length}} + + + + + + + + + + {{#each colors as |c|}} + + + + + + {{/each}} + +
    {{i18n 'admin.customize.color'}}
    + {{c.translatedName}} +
    + {{c.description}} +
    {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} + + +
    + {{else}} +

    {{i18n 'search.no_results'}}

    + {{/if}} +
    +
    diff --git a/app/assets/javascripts/admin/templates/customize-colors.hbs b/app/assets/javascripts/admin/templates/customize-colors.hbs index 62b489075d0..354598068b3 100644 --- a/app/assets/javascripts/admin/templates/customize-colors.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors.hbs @@ -3,76 +3,16 @@ - + -{{#if selectedItem}} -
    -
    -

    {{text-field class="style-name" value=selectedItem.name}}

    - -
    - - - - - {{selectedItem.savingStatus}} -
    - -
    - -
    - -
    - - {{#if colors.length}} - - - - - - - - - - {{#each colors as |c|}} - - - - - - {{/each}} - -
    {{i18n 'admin.customize.color'}}
    - {{c.translatedName}} -
    - {{c.description}} -
    {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} - - -
    - {{else}} -

    {{i18n 'search.no_results'}}

    - {{/if}} -
    -
    -{{else}} -

    {{i18n 'admin.customize.colors.about'}}

    -{{/if}} +{{outlet}}
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs b/app/assets/javascripts/admin/templates/customize-css-html-show.hbs deleted file mode 100644 index 6a10c7ae0b2..00000000000 --- a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs +++ /dev/null @@ -1,75 +0,0 @@ -
    -
    - {{text-field class="style-name" value=model.name}} - {{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}} - -
    - -
    - -
    - {{#if cssActive}}{{ace-editor content=model.stylesheet mode="scss"}}{{/if}} - {{#if headerActive}}{{ace-editor content=model.header mode="html"}}{{/if}} - {{#if topActive}}{{ace-editor content=model.top mode="html"}}{{/if}} - {{#if footerActive}}{{ace-editor content=model.footer mode="html"}}{{/if}} - {{#if headTagActive}}{{ace-editor content=model.head_tag mode="html"}}{{/if}} - {{#if bodyTagActive}}{{ace-editor content=model.body_tag mode="html"}}{{/if}} - {{#if embeddedCssActive}}{{ace-editor content=model.embedded_css mode="css"}}{{/if}} - {{#if mobileCssActive}}{{ace-editor content=model.mobile_stylesheet mode="scss"}}{{/if}} - {{#if mobileHeaderActive}}{{ace-editor content=model.mobile_header mode="html"}}{{/if}} - {{#if mobileTopActive}}{{ace-editor content=model.mobile_top mode="html"}}{{/if}} - {{#if mobileFooterActive}}{{ace-editor content=model.mobile_footer mode="html"}}{{/if}} -
    - - -
    -
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html.hbs b/app/assets/javascripts/admin/templates/customize-css-html.hbs deleted file mode 100644 index 73b8e22c9f2..00000000000 --- a/app/assets/javascripts/admin/templates/customize-css-html.hbs +++ /dev/null @@ -1,13 +0,0 @@ -
    -

    {{i18n 'admin.customize.css_html.long_title'}}

    - - - {{d-button label="admin.customize.new" icon="plus" action="newCustomization" class="btn-primary"}} - {{d-button action="importModal" icon="upload" label="admin.customize.import"}} -
    - -{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs new file mode 100644 index 00000000000..fbdafdd4139 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs @@ -0,0 +1,62 @@ +
    +
    +

    {{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}

    + + + +
    + +
    + +
    +
    + {{ace-editor content=activeSection mode=activeSectionMode}} +
    + + +
    +
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html-index.hbs b/app/assets/javascripts/admin/templates/customize-themes-index.hbs similarity index 100% rename from app/assets/javascripts/admin/templates/customize-css-html-index.hbs rename to app/assets/javascripts/admin/templates/customize-themes-index.hbs diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs new file mode 100644 index 00000000000..5bbd9cb2ee1 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -0,0 +1,112 @@ +
    +

    + {{#if editingName}} + {{text-field value=model.name autofocus="true"}} + {{d-button action="finishedEditingName" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelEditingName" class="btn-small cancel-edit" icon="times"}} + {{else}} + {{model.name}} {{fa-icon "pencil"}} + {{/if}} +

    + + {{#if model.remote_theme}} +

    + {{i18n "admin.customize.theme.about_theme"}} +

    + {{#if model.remote_theme.license_url}} +

    + {{i18n "admin.customize.theme.license"}} {{fa-icon "copyright"}} +

    + {{/if}} + {{/if}} + + +

    + {{inline-edit-checkbox action="applyDefault" labelKey="admin.customize.theme.is_default" checked=model.default}} + {{inline-edit-checkbox action="applyUserSelectable" labelKey="admin.customize.theme.user_selectable" checked=model.user_selectable}} +

    + + {{#if showSchemes}} +

    {{i18n "admin.customize.theme.color_scheme"}}

    +

    {{i18n "admin.customize.theme.color_scheme_select"}}

    +

    {{combo-box content=colorSchemes + nameProperty="name" + value=colorSchemeId + valueAttribute="id"}} + {{#if colorSchemeChanged}} + {{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}} + {{/if}} +

    + {{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}} + {{/if}} + +

    {{i18n "admin.customize.theme.css_html"}}

    + {{#if hasEditedFields}} + +

    {{i18n "admin.customize.theme.custom_sections"}}

    + + {{else}} +

    + {{i18n "admin.customize.theme.edit_css_html_help"}} +

    + {{/if}} +

    + {{#if model.remote_theme}} + {{#if model.remote_theme.commits_behind}} + {{#d-button action="updateToLatest" icon="download"}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}} + {{else}} + {{#d-button action="checkForThemeUpdates" icon="refresh"}}{{i18n "admin.customize.theme.check_for_updates"}}{{/d-button}} + {{/if}} + {{/if}} + {{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}} + {{#if model.remote_theme}} + + {{#if updatingRemote}} + {{i18n 'admin.customize.theme.updating'}} + {{else}} + {{#if model.remote_theme.commits_behind}} + {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} + {{else}} + {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} + {{/if}} + {{/if}} + + {{/if}} +

    + + {{#if availableChildThemes}} +

    {{i18n "admin.customize.theme.included_themes"}}

    + {{#unless model.childThemes.length}} +

    + +

    + {{else}} + + {{/unless}} + {{#if selectableChildThemes}} +

    {{combo-box content=selectableChildThemes + nameProperty="name" + value=selectedChildThemeId + valueAttribute="id"}} + + {{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}} +

    + {{/if}} + {{/if}} + + {{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}} + + {{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}} +
    diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs new file mode 100644 index 00000000000..ce8f6820759 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes.hbs @@ -0,0 +1,24 @@ +{{#unless editingTheme}} +
    +

    {{i18n 'admin.customize.theme.long_title'}}

    + + + {{d-button label="admin.customize.new" icon="plus" action="newTheme" class="btn-primary"}} + {{d-button action="importModal" icon="upload" label="admin.customize.import"}} +
    +{{/unless}} +{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs index 7696b34811f..3065c09855c 100644 --- a/app/assets/javascripts/admin/templates/customize.hbs +++ b/app/assets/javascripts/admin/templates/customize.hbs @@ -1,7 +1,7 @@
    {{#admin-nav}} + {{nav-item route='adminCustomizeThemes' label='admin.customize.theme.title'}} {{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}} - {{nav-item route='adminCustomizeCssHtml' label='admin.customize.css_html.title'}} {{nav-item route='adminSiteText' label='admin.site_text.title'}} {{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}} {{nav-item route='adminUserFields' label='admin.user_fields.title'}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs new file mode 100644 index 00000000000..5286bbf0b07 --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs @@ -0,0 +1,12 @@ +
    + {{#d-modal-body title="admin.customize.colors.select_base.title"}} + {{i18n "admin.customize.colors.select_base.description"}} + {{combo-box content=model + nameProperty="name" + value=selectedBaseThemeId + valueAttribute="base_scheme_id"}} + {{/d-modal-body}} + +
    diff --git a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs new file mode 100644 index 00000000000..7c4058d52da --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs @@ -0,0 +1,27 @@ +{{#d-modal-body class='upload-selector' title="admin.customize.theme.import_theme"}} +
    + {{radio-button name="upload" id="local" value="local" selection=selection}} + + {{#if local}} +
    +
    + {{i18n 'admin.customize.theme.import_file_tip'}} +
    + {{/if}} +
    +
    + {{radio-button name="upload" id="remote" value="remote" selection=selection}} + + {{#if remote}} +
    + {{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}} + {{i18n 'admin.customize.theme.import_web_tip'}} +
    + {{/if}} +
    +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs b/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs new file mode 100644 index 00000000000..3fbaf0ac865 --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-theme-change.hbs @@ -0,0 +1,8 @@ +
    + {{#d-modal-body title="admin.logs.staff_actions.modal_title"}} + {{{diff}}} + {{/d-modal-body}} + +
    diff --git a/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs b/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs deleted file mode 100644 index bbacea995c9..00000000000 --- a/app/assets/javascripts/admin/templates/modal/site-customization-change.hbs +++ /dev/null @@ -1,29 +0,0 @@ -
    - - {{#d-modal-body title="admin.logs.staff_actions.modal_title"}} - - - {{/d-modal-body}} - -
    diff --git a/app/assets/javascripts/discourse-common/components/combo-box.js.es6 b/app/assets/javascripts/discourse-common/components/combo-box.js.es6 index 2a2c4e2ba77..b2c66fc127f 100644 --- a/app/assets/javascripts/discourse-common/components/combo-box.js.es6 +++ b/app/assets/javascripts/discourse-common/components/combo-box.js.es6 @@ -22,17 +22,40 @@ export default Ember.Component.extend(bufferedRender({ let selected = this.get('value'); if (!Em.isNone(selected)) { selected = selected.toString(); } - if (this.get('content')) { - this.get('content').forEach(o => { + let selectedFound = false; + let firstVal = undefined; + const content = this.get('content'); + + if (content) { + let first = true; + content.forEach(o => { let val = o[this.get('valueAttribute')]; if (typeof val === "undefined") { val = o; } if (!Em.isNone(val)) { val = val.toString(); } const selectedText = (val === selected) ? "selected" : ""; const name = Handlebars.Utils.escapeExpression(Ember.get(o, nameProperty) || o); + + if (val === selected) { + selectedFound = true; + } + if (first) { + firstVal = val; + first = false; + } buffer.push(``); }); } + + if (!selectedFound) { + if (none) { + this.set('value', null); + } else { + this.set('value', firstVal); + } + } + + Ember.run.scheduleOnce('afterRender', this, this._updateSelect2); }, @observes('value') @@ -81,9 +104,14 @@ export default Ember.Component.extend(bufferedRender({ } this.set('value', val); }); + Ember.run.scheduleOnce('afterRender', this, this._triggerChange); }, + _updateSelect2() { + this.$().trigger('change.select2'); + }, + _triggerChange() { this.$().trigger('change'); }, diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6 index 72d05fb7dde..b56ac1628aa 100644 --- a/app/assets/javascripts/discourse/adapters/rest.js.es6 +++ b/app/assets/javascripts/discourse/adapters/rest.js.es6 @@ -1,7 +1,7 @@ import { ajax } from 'discourse/lib/ajax'; import { hashString } from 'discourse/lib/hash'; -const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host', 'web-hook', 'web-hook-event']; +const ADMIN_MODELS = ['plugin', 'theme', 'embeddable-host', 'web-hook', 'web-hook-event']; export function Result(payload, responseJson) { this.payload = payload; @@ -76,22 +76,38 @@ export default Ember.Object.extend({ this.cached[this.storageKey(type,findArgs,opts)] = hydrated; }, + jsonMode: false, + + getPayload(method, data) { + let payload = {method, data}; + + if (this.jsonMode) { + payload.contentType = "application/json"; + payload.data = JSON.stringify(data); + } + + return payload; + }, + update(store, type, id, attrs) { const data = {}; const typeField = Ember.String.underscore(type); data[typeField] = attrs; - return ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) { - return new Result(json[typeField], json); - }); + + return ajax(this.pathFor(store, type, id), this.getPayload('PUT', data)) + .then(function(json) { + return new Result(json[typeField], json); + }); }, createRecord(store, type, attrs) { const data = {}; const typeField = Ember.String.underscore(type); data[typeField] = attrs; - return ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) { - return new Result(json[typeField], json); - }); + return ajax(this.pathFor(store, type), this.getPayload('POST', data)) + .then(function (json) { + return new Result(json[typeField], json); + }); }, destroyRecord(store, type, record) { diff --git a/app/assets/javascripts/discourse/components/json-file-uploader.js.es6 b/app/assets/javascripts/discourse/components/json-file-uploader.js.es6 deleted file mode 100644 index e29491d48a1..00000000000 --- a/app/assets/javascripts/discourse/components/json-file-uploader.js.es6 +++ /dev/null @@ -1,103 +0,0 @@ - -export default Em.Component.extend({ - fileInput: null, - loading: false, - expectedRootObjectName: null, - hover: 0, - - classNames: ['json-uploader'], - - _initialize: function() { - const $this = this.$(); - const self = this; - - const $fileInput = $this.find('#js-file-input'); - this.set('fileInput', $fileInput[0]); - - $fileInput.on('change', function() { - self.fileSelected(this.files); - }); - - $this.on('dragover', function(e) { - if (e.preventDefault) e.preventDefault(); - return false; - }); - $this.on('dragenter', function(e) { - if (e.preventDefault) e.preventDefault(); - self.set('hover', self.get('hover') + 1); - return false; - }); - $this.on('dragleave', function(e) { - if (e.preventDefault) e.preventDefault(); - self.set('hover', self.get('hover') - 1); - return false; - }); - $this.on('drop', function(e) { - if (e.preventDefault) e.preventDefault(); - - self.set('hover', 0); - self.fileSelected(e.dataTransfer.files); - return false; - }); - - }.on('didInsertElement'), - - accept: function() { - return ".json,application/json,application/x-javascript,text/json" + (this.get('extension') ? "," + this.get('extension') : ""); - }.property('extension'), - - setReady: function() { - let parsed; - try { - parsed = JSON.parse(this.get('value')); - } catch (e) { - this.set('ready', false); - return; - } - - const rootObject = parsed[this.get('expectedRootObjectName')]; - - if (rootObject !== null && rootObject !== undefined) { - this.set('ready', true); - } else { - this.set('ready', false); - } - }.observes('destination', 'expectedRootObjectName'), - - actions: { - selectFile: function() { - const $fileInput = $(this.get('fileInput')); - $fileInput.click(); - } - }, - - fileSelected(fileList) { - const self = this; - let files = []; - for (let i = 0; i < fileList.length; i++) { - files[i] = fileList[i]; - } - const fileNameRegex = /\.(json|txt)$/; - files = files.filter(function(file) { - if (fileNameRegex.test(file.name)) { - return true; - } - if (file.type === "text/plain") { - return true; - } - return false; - }); - const firstFile = fileList[0]; - - this.set('loading', true); - - let reader = new FileReader(); - reader.onload = function(evt) { - self.set('value', evt.target.result); - self.set('loading', false); - }; - - reader.readAsText(firstFile); - } - -}); diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index dd6910d18a7..0bc886a8178 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -143,12 +143,14 @@ export default Em.Component.extend({ matchRegEx = matchRegEx || replaceRegEx; const match = this.filterBlocks(matchRegEx); + let val = this.get(key); + if (match.length !== 0) { const userInput = match[0].replace(replaceRegEx, ''); - if (this.get(key) !== userInput) { + if (val !== userInput) { this.set(key, userInput); } - } else if(this.get(key).length !== 0) { + } else if(val && val.length !== 0) { this.set(key, ''); } }, diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 5ee945d4da2..9173a771539 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -1,12 +1,23 @@ import { setting } from 'discourse/lib/computed'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; import { popupAjaxError } from 'discourse/lib/ajax-error'; -import computed from "ember-addons/ember-computed-decorators"; +import { default as computed, observes } from "ember-addons/ember-computed-decorators"; import { cook } from 'discourse/lib/text'; import { NotificationLevels } from 'discourse/lib/notification-levels'; +import { listThemes, selectDefaultTheme, previewTheme } from 'discourse/lib/theme-selector'; export default Ember.Controller.extend(CanCheckEmails, { + userSelectableThemes: function(){ + return listThemes(this.site); + }.property(), + + @observes("selectedTheme") + themeKeyChanged() { + let key = this.get("selectedTheme"); + previewTheme(key); + }, + @computed("model.watchedCategories", "model.trackedCategories", "model.mutedCategories") selectedCategories(watched, tracked, muted) { return [].concat(watched, tracked, muted); @@ -162,6 +173,7 @@ export default Ember.Controller.extend(CanCheckEmails, { Discourse.User.currentProp('name', model.get('name')); } model.set('bio_cooked', cook(model.get('bio_raw'))); + selectDefaultTheme(this.get('selectedTheme')); this.set('saved', true); }).catch(popupAjaxError); }, diff --git a/app/assets/javascripts/discourse/controllers/upload-customization.js.es6 b/app/assets/javascripts/discourse/controllers/upload-customization.js.es6 deleted file mode 100644 index ae7575887a9..00000000000 --- a/app/assets/javascripts/discourse/controllers/upload-customization.js.es6 +++ /dev/null @@ -1,30 +0,0 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -export default Ember.Controller.extend(ModalFunctionality, { - notReady: Em.computed.not('ready'), - adminCustomizeCssHtml: Ember.inject.controller(), - - ready: function() { - try { - const parsed = JSON.parse(this.get('customizationFile')); - return !!parsed["site_customization"]; - } catch (e) { - return false; - } - }.property('customizationFile'), - - actions: { - createCustomization: function() { - const object = JSON.parse(this.get('customizationFile')).site_customization; - - // Slight fixup before creating object - object.enabled = false; - delete object.id; - delete object.key; - - const controller = this.get('adminCustomizeCssHtml'); - controller.send('newCustomization', object); - } - } - -}); diff --git a/app/assets/javascripts/discourse/initializers/live-development.js.es6 b/app/assets/javascripts/discourse/initializers/live-development.js.es6 index f73172e154e..de6994b9324 100644 --- a/app/assets/javascripts/discourse/initializers/live-development.js.es6 +++ b/app/assets/javascripts/discourse/initializers/live-development.js.es6 @@ -1,5 +1,45 @@ import DiscourseURL from 'discourse/lib/url'; +export function refreshCSS(node, hash, newHref, options) { + + let $orig = $(node); + + if ($orig.data('reloading')) { + + if (options && options.force) { + clearTimeout($orig.data('timeout')); + $orig.data("copy").remove(); + } else { + return; + } + } + + if (!$orig.data('orig')) { + $orig.data('orig', node.href); + } + + $orig.data('reloading', true); + + const orig = $(node).data('orig'); + + let reloaded = $orig.clone(true); + if (hash) { + reloaded[0].href = orig + (orig.indexOf('?') >= 0 ? "&hash=" : "?hash=") + hash; + } else { + reloaded[0].href = newHref; + } + + $orig.after(reloaded); + + let timeout = setTimeout(()=>{ + $orig.remove(); + reloaded.data('reloading', false); + }, 2000); + + $orig.data("timeout", timeout); + $orig.data("copy", reloaded); +} + // Use the message bus for live reloading of components for faster development. export default { name: "live-development", @@ -48,17 +88,8 @@ export default { document.location.reload(true); } else { $('link').each(function() { - // TODO: stop bundling css in DEV please - if (true || (this.href.match(me.name) && me.hash)) { - if (!$(this).data('orig')) { - $(this).data('orig', this.href); - } - const orig = $(this).data('orig'); - if (!me.hash) { - window.__uniq = window.__uniq || 1; - me.hash = window.__uniq++; - } - this.href = orig + (orig.indexOf('?') >= 0 ? "&hash=" : "?hash=") + me.hash; + if (this.href.match(me.name) && (me.hash || me.new_href)) { + refreshCSS(this, me.hash, me.new_href); } }); } diff --git a/app/assets/javascripts/discourse/lib/load-script.js.es6 b/app/assets/javascripts/discourse/lib/load-script.js.es6 index cc24df86de8..13dbbb6ecf4 100644 --- a/app/assets/javascripts/discourse/lib/load-script.js.es6 +++ b/app/assets/javascripts/discourse/lib/load-script.js.es6 @@ -24,6 +24,10 @@ function loadWithTag(path, cb) { }; } +export function loadCSS(url) { + return loadScript(url, { css: true }); +} + export default function loadScript(url, opts) { // TODO: Remove this once plugins have been updated not to use it: @@ -47,8 +51,11 @@ export default function loadScript(url, opts) { delete _loading[url]; }); - const cb = function() { + const cb = function(data) { _loaded[url] = true; + if (opts && opts.css) { + $("head").append(""); + } done(); resolve(); }; @@ -66,7 +73,7 @@ export default function loadScript(url, opts) { if (opts.scriptTag) { loadWithTag(cdnUrl, cb); } else { - ajax({url: cdnUrl, dataType: "script", cache: true}).then(cb); + ajax({url: cdnUrl, dataType: opts.css ? "text": "script", cache: true}).then(cb); } }); } diff --git a/app/assets/javascripts/discourse/lib/theme-selector.js.es6 b/app/assets/javascripts/discourse/lib/theme-selector.js.es6 new file mode 100644 index 00000000000..de7a81daf93 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/theme-selector.js.es6 @@ -0,0 +1,62 @@ +import { ajax } from 'discourse/lib/ajax'; +import { refreshCSS } from 'discourse/initializers/live-development'; +const keySelector = 'meta[name=discourse_theme_key]'; + +function currentThemeKey() { + let themeKey = null; + let elem = _.first($(keySelector)); + if (elem) { + themeKey = elem.content; + } + return themeKey; +} + +export function selectDefaultTheme(key) { + if (key) { + $.cookie('preview_style', key); + } else { + $.cookie('preview_style', null); + } +} + +export function previewTheme(key) { + if (currentThemeKey() !== key) { + + Discourse.set("assetVersion", "forceRefresh"); + + ajax(`/themes/assets/${key ? key : 'default'}`).then(results => { + let elem = _.first($(keySelector)); + if (elem) { + elem.content = key; + } + + results.themes.forEach(theme => { + let node = $(`link[rel=stylesheet][data-target=${theme.target}]`)[0]; + if (node) { + refreshCSS(node, null, theme.url, {force: true}); + } + }); + }); + } +} + +export function listThemes(site) { + let themes = site.get('user_themes'); + + if (!themes) { + return null; + } + + let hasDefault = !!themes.findBy('default', true); + + let results = []; + if (!hasDefault) { + results.push({name: I18n.t('themes.default_description'), id: null}); + } + + themes.forEach(t=>{ + results.push({name: t.name, id: t.theme_key}); + }); + + return results.length === 0 ? null : results; +} diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 753f2dd5abf..0d1ef7cdac0 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -56,9 +56,13 @@ export default Ember.Object.extend({ }, findAll(type, findArgs) { - const self = this; - return this.adapterFor(type).findAll(this, type, findArgs).then(function(result) { - return self._resultSet(type, result); + const adapter = this.adapterFor(type); + return adapter.findAll(this, type, findArgs).then((result) => { + let results = this._resultSet(type, result); + if (adapter.afterFindAll) { + results = adapter.afterFindAll(results); + } + return results; }); }, diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6 index b26648042c0..6de4f047671 100644 --- a/app/assets/javascripts/discourse/routes/preferences.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences.js.es6 @@ -11,7 +11,8 @@ export default RestrictedUserRoute.extend({ controller.reset(); controller.setProperties({ model: user, - newNameInput: user.get('name') + newNameInput: user.get('name'), + selectedTheme: $.cookie('preview_style') }); }, diff --git a/app/assets/javascripts/discourse/templates/components/color-input.hbs b/app/assets/javascripts/discourse/templates/components/color-input.hbs index e69d19d7e10..1b9f36ecb39 100644 --- a/app/assets/javascripts/discourse/templates/components/color-input.hbs +++ b/app/assets/javascripts/discourse/templates/components/color-input.hbs @@ -1 +1 @@ -{{text-field class="hex-input" value=hexValue maxlength="6"}} +{{text-field class="hex-input" value=hexValue maxlength="6"}} diff --git a/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs b/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs deleted file mode 100644 index b34f6c4b9f4..00000000000 --- a/app/assets/javascripts/discourse/templates/components/json-file-uploader.hbs +++ /dev/null @@ -1,12 +0,0 @@ -
    -
    - - {{d-button class="fileSelect" action="selectFile" class="" icon="upload" label="upload_selector.select_file"}} - {{conditional-loading-spinner condition=loading size="small"}} -
    -
    {{i18n "alternation"}}
    -
    - {{textarea value=value}} -
    -
    {{fa-icon "upload"}}
    -
    diff --git a/app/assets/javascripts/discourse/templates/preferences.hbs b/app/assets/javascripts/discourse/templates/preferences.hbs index 6fea4f7f6f7..3a0ec94d69b 100644 --- a/app/assets/javascripts/discourse/templates/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/preferences.hbs @@ -354,6 +354,15 @@
    {{/if}} + {{#if userSelectableThemes}} +
    + +
    + {{combo-box content=userSelectableThemes value=selectedTheme}} +
    +
    + {{/if}} + {{plugin-outlet name="user-custom-controls" args=(hash model=model)}}
    diff --git a/app/assets/javascripts/wizard/components/theme-preview.js.es6 b/app/assets/javascripts/wizard/components/theme-preview.js.es6 index 19a9863b155..d4ec95b2171 100644 --- a/app/assets/javascripts/wizard/components/theme-preview.js.es6 +++ b/app/assets/javascripts/wizard/components/theme-preview.js.es6 @@ -11,7 +11,7 @@ export default createPreviewComponent(659, 320, { logo: null, avatar: null, - @observes('step.fieldsById.theme_id.value') + @observes('step.fieldsById.base_scheme_id.value') themeChanged() { this.triggerRepaint(); }, diff --git a/app/assets/javascripts/wizard/models/wizard.js.es6 b/app/assets/javascripts/wizard/models/wizard.js.es6 index d98ba5e51ee..8960354e584 100644 --- a/app/assets/javascripts/wizard/models/wizard.js.es6 +++ b/app/assets/javascripts/wizard/models/wizard.js.es6 @@ -25,7 +25,7 @@ const Wizard = Ember.Object.extend({ const colorStep = this.get('steps').findBy('id', 'colors'); if (!colorStep) { return; } - const themeChoice = colorStep.get('fieldsById.theme_id'); + const themeChoice = colorStep.get('fieldsById.base_scheme_id'); if (!themeChoice) { return; } const themeId = themeChoice.get('value'); diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 0fba7c199bc..fc3044045f4 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -3,6 +3,8 @@ @import "common/foundation/mixins"; @import "common/foundation/helpers"; +@import "common/admin/customize"; + $mobile-breakpoint: 700px; // Change the box model for .admin-content @@ -724,138 +726,6 @@ section.details { } } -// Customise area -.customize { - .admin-footer { - margin-top: 20px; - } - .current-style.maximized { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: 100000; - background-color: white; - width: 100%; - padding: 0; - margin: 0; - .wrapper { - position: absolute; - top: 20px; - bottom: 10px; - left: 20px; - right: 20px; - } - } - .nav.nav-pills { - margin-left: 10px; - } - .content-list, .current-style { - float: left; - } - .content-list ul { - margin-bottom: 10px; - } - .current-style { - .nav.nav-pills{ - position: relative; - } - .toggle-mobile { - position: absolute; - right: 35px; - font-size: 20px; - } - .toggle-maximize { - position: absolute; - right: -5px; - } - .delete-link { - margin-left: 15px; - margin-top: 5px; - } - .preview-link { - margin-left: 15px; - } - .export { - float: right; - } - padding-left: 10px; - width: 70%; - .style-name { - width: 350px; - height: 25px; - // Remove height to for `box-sizing: border-box` - height: auto; - } - .ace-wrapper { - position: relative; - height: 400px; - width: 100%; - } - &.maximized { - .admin-container { - position: absolute; - bottom: 50px; - top: 80px; - width: 100%; - } - .admin-footer { - position: absolute; - bottom: 10px; - } - .ace-wrapper { - height: 100%; - } - } - .ace_editor { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - } - .status-actions { - float: right; - margin-top: 7px; - span { - margin-right: 10px; - } - } - .buttons { - float: left; - width: 200px; - .saving { - padding: 5px 0 0 0; - margin-left: 10px; - width: 80px; - color: $primary; - } - } - } - .color-scheme { - .controls { - span, button, a { - margin-right: 10px; - } - } - } - .colors { - thead th { border: none; } - td.hex { width: 100px; } - td.actions { width: 200px; } - .hex-input { width: 80px; margin-bottom: 0; } - .hex { text-align: center; } - .description { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); } - - .invalid .hex input { - background-color: white; - color: black; - border-color: $danger; - } - } -} - .admin-flags { .hidden-post td.excerpt, .hidden-post td.user { @@ -1970,6 +1840,12 @@ table#user-badges { background: $secondary; } } + +.inline-edit label { + display: inline-block; + margin-right: 20px; +} + .cbox0 { background: blend-primary-secondary(0%); } .cbox10 { background: blend-primary-secondary(10%); } .cbox20 { background: blend-primary-secondary(20%); } diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss new file mode 100644 index 00000000000..6b726ec61fd --- /dev/null +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -0,0 +1,157 @@ +// Customise area +.customize { + .admin-container { + padding-left: 10px; + padding-right: 10px; + } + .admin-footer { + margin-top: 20px; + } + .show-current-style { + margin-left: 20px; + float: left; + width: 70%; + h2 { + margin-bottom: 15px; + } + h3 { + margin-bottom: 10px; + margin-top: 30px; + } + } + + .current-style.maximized { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 100000; + background-color: white; + width: 100%; + padding: 0; + margin: 0; + .wrapper { + position: absolute; + top: 20px; + bottom: 10px; + left: 20px; + right: 20px; + } + } + + .nav.nav-pills.fields { + margin-left: 10px; + } + .content-list, .current-style { + float: left; + } + .content-list ul { + margin-bottom: 10px; + } + .current-style { + width: 100%; + + .admin-container { + margin: 0; + } + + .nav.target { + margin-top: 15px; + .fa { + margin-left: 3px; + } + .fa-mobile { + font-size: 1.3em; + } + } + + .toggle-maximize { + position: absolute; + right: -5px; + } + + .ace-wrapper { + position: relative; + height: 600px; + width: 100%; + } + + &.maximized { + .admin-container { + position: absolute; + bottom: 50px; + top: 80px; + width: 100%; + } + .admin-footer { + position: absolute; + bottom: 10px; + } + .ace-wrapper { + height: 100%; + } + } + + .custom-ace-gutter { + width: 41px; + background-color: #ebebeb; + height: 15px; + } + + .ace_editor { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + + .status-actions { + float: right; + margin-top: 7px; + span { + margin-right: 10px; + } + } + + .buttons { + float: left; + width: 200px; + .saving { + padding: 5px 0 0 0; + margin-left: 10px; + width: 80px; + color: $primary; + } + } + } + .color-scheme { + .controls { + span, button, a { + margin-right: 10px; + } + } + } + .colors { + thead th { border: none; } + td.hex { width: 160px; } + td.actions { width: 200px; } + .hex-input { width: 80px; margin-bottom: 0; } + .hex { text-align: center; } + .description { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); } + + .invalid .hex input { + background-color: white; + color: black; + border-color: $danger; + } + } + + .status-message { + display: block; + font-size: 0.8em; + margin-top: 8px; + } +} + diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.scss similarity index 100% rename from app/assets/stylesheets/common/components/badges.css.scss rename to app/assets/stylesheets/common/components/badges.scss diff --git a/app/assets/stylesheets/common/components/banner.css.scss b/app/assets/stylesheets/common/components/banner.scss similarity index 100% rename from app/assets/stylesheets/common/components/banner.css.scss rename to app/assets/stylesheets/common/components/banner.scss diff --git a/app/assets/stylesheets/common/components/buttons.css.scss b/app/assets/stylesheets/common/components/buttons.scss similarity index 100% rename from app/assets/stylesheets/common/components/buttons.css.scss rename to app/assets/stylesheets/common/components/buttons.scss diff --git a/app/assets/stylesheets/common/components/date-picker.css.scss b/app/assets/stylesheets/common/components/date-picker.scss similarity index 100% rename from app/assets/stylesheets/common/components/date-picker.css.scss rename to app/assets/stylesheets/common/components/date-picker.scss diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss similarity index 100% rename from app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss rename to app/assets/stylesheets/common/components/keyboard_shortcuts.scss diff --git a/app/assets/stylesheets/common/components/navs.css.scss b/app/assets/stylesheets/common/components/navs.scss similarity index 100% rename from app/assets/stylesheets/common/components/navs.css.scss rename to app/assets/stylesheets/common/components/navs.scss diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index 2a3c5588d02..35b0418187d 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -1,5 +1,5 @@ -@import "common/foundation/variables"; -@import "common/foundation/mixins"; +@import "./variables"; +@import "./mixins"; // -------------------------------------------------- // Base styles for HTML elements diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index b06099e3b5f..43eb44cdb2d 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -28,7 +28,7 @@ $base-font-size: 14px !default; $base-line-height: 19px !default; $base-font-family: Helvetica, Arial, sans-serif !default; -/* These files don't actually exist. They're injected by DiscourseSassImporter. */ +/* These files don't actually exist. They're injected by Stylesheet::Compiler. */ @import "theme_variables"; @import "plugins_variables"; @import "common/foundation/math"; diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index 807b43c7260..71f19278eda 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -21,7 +21,7 @@ @import "desktop/menu-panel"; @import "desktop/group"; -/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ +/* These files doesn't actually exist, they are injected by Stylesheet::Compiler. */ @import "plugins"; @import "plugins_desktop"; diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.scss similarity index 97% rename from app/assets/stylesheets/embed.css.scss rename to app/assets/stylesheets/embed.scss index afb4a40c403..66af74d3d0b 100644 --- a/app/assets/stylesheets/embed.css.scss +++ b/app/assets/stylesheets/embed.scss @@ -1,6 +1,5 @@ -//= require ./vendor/normalize -//= require ./common/foundation/base - +@import "./vendor/normalize"; +@import "./common/foundation/base"; @import "./common/foundation/variables"; @import "./common/foundation/colors"; @import "./common/foundation/mixins"; diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index ce220071ee6..b1592b6d6ab 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -24,7 +24,7 @@ @import "mobile/ring"; @import "mobile/group"; -/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ +/* These files doesn't actually exist, they are injected by Stylesheet::Compiler. */ @import "plugins"; @import "plugins_mobile"; diff --git a/app/assets/stylesheets/vendor/sweetalert.css b/app/assets/stylesheets/vendor/sweetalert.scss old mode 100755 new mode 100644 similarity index 100% rename from app/assets/stylesheets/vendor/sweetalert.css rename to app/assets/stylesheets/vendor/sweetalert.scss diff --git a/app/controllers/admin/color_schemes_controller.rb b/app/controllers/admin/color_schemes_controller.rb index 35f45c6e4ad..dda6c9f07ce 100644 --- a/app/controllers/admin/color_schemes_controller.rb +++ b/app/controllers/admin/color_schemes_controller.rb @@ -3,7 +3,7 @@ class Admin::ColorSchemesController < Admin::AdminController before_filter :fetch_color_scheme, only: [:update, :destroy] def index - render_serialized([ColorScheme.base] + ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer) + render_serialized(ColorScheme.base_color_schemes + ColorScheme.order('id ASC').all.to_a, ColorSchemeSerializer) end def create @@ -37,6 +37,6 @@ class Admin::ColorSchemesController < Admin::AdminController end def color_scheme_params - params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex]])[:color_scheme] + params.permit(color_scheme: [:base_scheme_id, :name, colors: [:name, :hex]])[:color_scheme] end end diff --git a/app/controllers/admin/site_customizations_controller.rb b/app/controllers/admin/site_customizations_controller.rb deleted file mode 100644 index afd3c162e57..00000000000 --- a/app/controllers/admin/site_customizations_controller.rb +++ /dev/null @@ -1,92 +0,0 @@ -class Admin::SiteCustomizationsController < Admin::AdminController - - before_filter :enable_customization - - skip_before_filter :check_xhr, only: [:show] - - def index - @site_customizations = SiteCustomization.order(:name) - - respond_to do |format| - format.json { render json: @site_customizations } - end - end - - def create - @site_customization = SiteCustomization.new(site_customization_params) - @site_customization.user_id = current_user.id - - respond_to do |format| - if @site_customization.save - log_site_customization_change(nil, site_customization_params) - format.json { render json: @site_customization, status: :created} - else - format.json { render json: @site_customization.errors, status: :unprocessable_entity } - end - end - end - - def update - @site_customization = SiteCustomization.find(params[:id]) - log_record = log_site_customization_change(@site_customization, site_customization_params) - - respond_to do |format| - if @site_customization.update_attributes(site_customization_params) - format.json { render json: @site_customization, status: :created} - else - log_record.destroy if log_record - format.json { render json: @site_customization.errors, status: :unprocessable_entity } - end - end - end - - def destroy - @site_customization = SiteCustomization.find(params[:id]) - StaffActionLogger.new(current_user).log_site_customization_destroy(@site_customization) - @site_customization.destroy - - respond_to do |format| - format.json { head :no_content } - end - end - - def show - @site_customization = SiteCustomization.find(params[:id]) - - respond_to do |format| - format.json do - check_xhr - render json: SiteCustomizationSerializer.new(@site_customization) - end - - format.any(:html, :text) do - raise RenderEmpty.new if request.xhr? - - response.headers['Content-Disposition'] = "attachment; filename=#{@site_customization.name.parameterize}.dcstyle.json" - response.sending_file = true - render json: SiteCustomizationSerializer.new(@site_customization) - end - end - - end - - private - - def site_customization_params - params.require(:site_customization) - .permit(:name, :stylesheet, :header, :top, :footer, - :mobile_stylesheet, :mobile_header, :mobile_top, :mobile_footer, - :head_tag, :body_tag, - :position, :enabled, :key, - :stylesheet_baked, :embedded_css) - end - - def log_site_customization_change(old_record, new_params) - StaffActionLogger.new(current_user).log_site_customization_change(old_record, new_params) - end - - def enable_customization - session[:disable_customization] = false - end - -end diff --git a/app/controllers/admin/staff_action_logs_controller.rb b/app/controllers/admin/staff_action_logs_controller.rb index 5324aabc0dd..b004601014f 100644 --- a/app/controllers/admin/staff_action_logs_controller.rb +++ b/app/controllers/admin/staff_action_logs_controller.rb @@ -6,4 +6,73 @@ class Admin::StaffActionLogsController < Admin::AdminController render_serialized(staff_action_logs, UserHistorySerializer) end + def diff + require_dependency "discourse_diff" + + @history = UserHistory.find(params[:id]) + prev = @history.previous_value + cur = @history.new_value + + prev = JSON.parse(prev) if prev + cur = JSON.parse(cur) if cur + + diff_fields = {} + + output = "

    #{CGI.escapeHTML(cur["name"].to_s)}

    " + + diff_fields["name"] = { + prev: prev["name"].to_s, + cur: cur["name"].to_s, + } + + ["default", "user_selectable"].each do |f| + diff_fields[f] = { + prev: (!!prev[f]).to_s, + cur: (!!cur[f]).to_s + } + end + + diff_fields["color scheme"] = { + prev: prev["color_scheme"]&.fetch("name").to_s, + cur: cur["color_scheme"]&.fetch("name").to_s, + } + + diff_fields["included themes"] = { + prev: child_themes(prev), + cur: child_themes(cur) + } + + + load_diff(diff_fields, :cur, cur) + load_diff(diff_fields, :prev, prev) + + diff_fields.delete_if{|k,v| v[:cur] == v[:prev]} + + + diff_fields.each do |k,v| + output << "

    #{k}

    " + diff = DiscourseDiff.new(v[:prev] || "", v[:cur] || "") + output << diff.side_by_side_markdown + end + + render json: {side_by_side: output} + end + + protected + + def child_themes(theme) + return "" unless children = theme["child_themes"] + + children.map{|row| row["name"]}.join(" ").to_s + end + + def load_diff(hash, key, val) + if f=val["theme_fields"] + f.each do |row| + entry = hash[row["target"] + " " + row["name"]] ||= {} + entry[key] = row["value"] + end + end + end + end diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb new file mode 100644 index 00000000000..083cc61359f --- /dev/null +++ b/app/controllers/admin/themes_controller.rb @@ -0,0 +1,192 @@ +class Admin::ThemesController < Admin::AdminController + + skip_before_filter :check_xhr, only: [:show] + + def import + + @theme = nil + if params[:theme] + json = JSON::parse(params[:theme].read) + theme = json['theme'] + + @theme = Theme.new(name: theme["name"], user_id: current_user.id) + theme["theme_fields"]&.each do |field| + @theme.set_field(field["target"], field["name"], field["value"]) + end + + if @theme.save + log_theme_change(nil, @theme) + render json: @theme, status: :created + else + render json: @theme.errors, status: :unprocessable_entity + end + elsif params[:remote] + @theme = RemoteTheme.import_theme(params[:remote]) + render json: @theme, status: :created + else + render json: @theme.errors, status: :unprocessable_entity + end + + end + + def index + @theme = Theme.order(:name).includes(:theme_fields, :remote_theme) + @color_schemes = ColorScheme.all.to_a + light = ColorScheme.new(name: I18n.t("color_schemes.default")) + @color_schemes.unshift(light) + + payload = { + themes: ActiveModel::ArraySerializer.new(@theme, each_serializer: ThemeSerializer), + extras: { + color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer) + } + } + + respond_to do |format| + format.json { render json: payload} + end + end + + def create + @theme = Theme.new(name: theme_params[:name], + user_id: current_user.id, + user_selectable: theme_params[:user_selectable] || false, + color_scheme_id: theme_params[:color_scheme_id]) + set_fields + + respond_to do |format| + if @theme.save + update_default_theme + log_theme_change(nil, @theme) + format.json { render json: @theme, status: :created} + else + format.json { render json: @theme.errors, status: :unprocessable_entity } + end + end + end + + def update + @theme = Theme.find(params[:id]) + + original_json = ThemeSerializer.new(@theme, root: false).to_json + + [:name, :color_scheme_id, :user_selectable].each do |field| + if theme_params.key?(field) + @theme.send("#{field}=", theme_params[field]) + end + end + + if theme_params.key?(:child_theme_ids) + expected = theme_params[:child_theme_ids].map(&:to_i) + + @theme.child_theme_relation.to_a.each do |child| + if expected.include?(child.child_theme_id) + expected.reject!{|id| id == child.child_theme_id} + else + child.destroy + end + end + + Theme.where(id: expected).each do |theme| + @theme.add_child_theme!(theme) + end + + end + + set_fields + + if params[:theme][:remote_check] + @theme.remote_theme.update_remote_version + @theme.remote_theme.save! + end + + if params[:theme][:remote_update] + @theme.remote_theme.update_from_remote + @theme.remote_theme.save! + end + + respond_to do |format| + if @theme.save + + update_default_theme + + log_theme_change(original_json, @theme) + format.json { render json: @theme, status: :created} + else + format.json { render json: @theme.errors, status: :unprocessable_entity } + end + end + end + + def destroy + @theme = Theme.find(params[:id]) + StaffActionLogger.new(current_user).log_theme_destroy(@theme) + @theme.destroy + + respond_to do |format| + format.json { head :no_content } + end + end + + def show + @theme = Theme.find(params[:id]) + + respond_to do |format| + format.json do + check_xhr + render json: ThemeSerializer.new(@theme) + end + + format.any(:html, :text) do + raise RenderEmpty.new if request.xhr? + + response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json" + response.sending_file = true + render json: ThemeSerializer.new(@theme) + end + end + + end + + private + + def update_default_theme + if theme_params.key?(:default) + is_default = theme_params[:default] + if @theme.key == SiteSetting.default_theme_key && !is_default + Theme.clear_default! + elsif is_default + @theme.set_default! + end + end + end + + def theme_params + @theme_params ||= + begin + # deep munge is a train wreck, work around it for now + params[:theme][:child_theme_ids] ||= [] if params[:theme].key?(:child_theme_ids) + params.require(:theme) + .permit(:name, + :color_scheme_id, + :default, + :user_selectable, + theme_fields: [:name, :target, :value], + child_theme_ids: []) + end + end + + def set_fields + + return unless fields = theme_params[:theme_fields] + + fields.each do |field| + @theme.set_field(field[:target], field[:name], field[:value]) + end + end + + def log_theme_change(old_record, new_record) + StaffActionLogger.new(current_user).log_theme_change(old_record, new_record) + end + +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d7a37a3a456..f0856406b4f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -411,8 +411,8 @@ class ApplicationController < ActionController::Base def custom_html_json target = view_context.mobile_view? ? :mobile : :desktop data = { - top: SiteCustomization.custom_top(session[:preview_style], target), - footer: SiteCustomization.custom_footer(session[:preview_style], target) + top: Theme.lookup_field(session[:preview_style], target, "after_header"), + footer: Theme.lookup_field(session[:preview_style], target, "footer") } if DiscoursePluginRegistry.custom_html diff --git a/app/controllers/site_customizations_controller.rb b/app/controllers/site_customizations_controller.rb deleted file mode 100644 index 34a314720fe..00000000000 --- a/app/controllers/site_customizations_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -class SiteCustomizationsController < ApplicationController - skip_before_filter :preload_json, :check_xhr, :redirect_to_login_if_required - - def show - no_cookies - - cache_time = request.env["HTTP_IF_MODIFIED_SINCE"] - cache_time = Time.rfc2822(cache_time) rescue nil if cache_time - stylesheet_time = - begin - if params[:key].to_s == SiteCustomization::ENABLED_KEY - SiteCustomization.where(enabled: true) - .order('created_at desc') - .limit(1) - .pluck(:created_at) - .first - else - SiteCustomization.where(key: params[:key].to_s).pluck(:created_at).first - end - end - - if !stylesheet_time - raise Discourse::NotFound - end - - if cache_time && stylesheet_time <= cache_time - return render nothing: true, status: 304 - end - - response.headers["Last-Modified"] = stylesheet_time.httpdate - expires_in 1.year, public: true - render text: SiteCustomization.stylesheet_contents(params[:key], params[:target]), - content_type: "text/css" - end -end diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb index 55c5166d4f1..dea3d5d34c5 100644 --- a/app/controllers/stylesheets_controller.rb +++ b/app/controllers/stylesheets_controller.rb @@ -1,12 +1,40 @@ class StylesheetsController < ApplicationController - skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show] + skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_source_map] + + def show_source_map + show_resource(source_map: true) + end def show + show_resource + end + + protected + + def show_resource(source_map: false) + + extension = source_map ? ".css.map" : ".css" + + params[:name] no_cookies target,digest = params[:name].split(/_([a-f0-9]{40})/) + if Rails.env == "development" + # TODO add theme + # calling this method ensures we have a cache for said target + # we hold of re-compilation till someone asks for asset + if target.include?("theme") + split_target,theme_id = target.split(/_(-?[0-9]+)/) + theme = Theme.find(theme_id) if theme_id + else + split_target,color_scheme_id = target.split(/_(-?[0-9]+)/) + theme = Theme.find_by(color_scheme_id: color_scheme_id) + end + Stylesheet::Manager.stylesheet_link_tag(split_target, nil, theme&.key) + end + cache_time = request.env["HTTP_IF_MODIFIED_SINCE"] cache_time = Time.rfc2822(cache_time) rescue nil if cache_time @@ -19,7 +47,7 @@ class StylesheetsController < ApplicationController # Security note, safe due to route constraint underscore_digest = digest ? "_" + digest : "" - location = "#{Rails.root}/#{DiscourseStylesheets::CACHE_PATH}/#{target}#{underscore_digest}.css" + location = "#{Rails.root}/#{Stylesheet::Manager::CACHE_PATH}/#{target}#{underscore_digest}#{extension}" stylesheet_time = query.pluck(:created_at).first @@ -33,24 +61,31 @@ class StylesheetsController < ApplicationController unless File.exist?(location) - if current = query.first - File.write(location, current.content) + if current = query.limit(1).pluck(source_map ? :source_map : :content).first + File.write(location, current) else raise Discourse::NotFound end end - response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time - immutable_for(1.year) unless Rails.env == "development" + if Rails.env == "development" + response.headers['Last-Modified'] = Time.zone.now.httpdate + immutable_for(1.second) + else + response.headers['Last-Modified'] = stylesheet_time.httpdate if stylesheet_time + immutable_for(1.year) + end send_file(location, disposition: :inline) end - protected - def handle_missing_cache(location, name, digest) + location = location.sub(".css.map", ".css") + source_map_location = location + ".map" + existing = File.read(location) rescue nil if existing && digest - StylesheetCache.add(name, digest, existing) + source_map = File.read(source_map_location) rescue nil + StylesheetCache.add(name, digest, existing, source_map) end end diff --git a/app/controllers/themes_controller.rb b/app/controllers/themes_controller.rb new file mode 100644 index 00000000000..a5f26c5cc4d --- /dev/null +++ b/app/controllers/themes_controller.rb @@ -0,0 +1,28 @@ +class ThemesController < ::ApplicationController + def assets + theme_key = params[:key].to_s + + if theme_key == "default" + theme_key = nil + else + raise Discourse::NotFound unless Theme.where(key: theme_key).exists? + end + + object = [:mobile, :desktop, :desktop_theme, :mobile_theme].map do |target| + link = Stylesheet::Manager.stylesheet_link_tag(target, 'all', params[:key]) + if link + href = link.split(/["']/)[1] + if Rails.env.development? + href << (href.include?("?") ? "&" : "?") + href << SecureRandom.hex + end + { + target: target, + url: href + } + end + end.compact + + render json: object.as_json + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a9e4ed40e8e..74ad085e700 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -316,4 +316,27 @@ module ApplicationHelper '' end end + + def theme_key + if customization_disabled? + nil + else + session[:preview_style] || SiteSetting.default_theme_key + end + end + + def theme_lookup(name) + lookup = Theme.lookup_field(theme_key, mobile_view? ? :mobile : :desktop, name) + lookup.html_safe if lookup + end + + def discourse_stylesheet_link_tag(name, opts={}) + if opts.key?(:theme_key) + key = opts[:theme_key] unless customization_disabled? + else + key = theme_key + end + + Stylesheet::Manager.stylesheet_link_tag(name, 'all', key) + end end diff --git a/app/models/category.rb b/app/models/category.rb index 2a48c6a53af..a533a3863fa 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,4 @@ require_dependency 'distributed_cache' -require_dependency 'sass/discourse_stylesheets' class Category < ActiveRecord::Base @@ -492,7 +491,7 @@ SQL end def publish_discourse_stylesheet - DiscourseStylesheets.cache.clear + Stylesheet::Manager.cache.clear end def index_search diff --git a/app/models/child_theme.rb b/app/models/child_theme.rb new file mode 100644 index 00000000000..6e101bd8aae --- /dev/null +++ b/app/models/child_theme.rb @@ -0,0 +1,20 @@ +class ChildTheme < ActiveRecord::Base + belongs_to :parent_theme, class_name: 'Theme' + belongs_to :child_theme, class_name: 'Theme' +end + +# == Schema Information +# +# Table name: child_themes +# +# id :integer not null, primary key +# parent_theme_id :integer +# child_theme_id :integer +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_child_themes_on_child_theme_id_and_parent_theme_id (child_theme_id,parent_theme_id) UNIQUE +# index_child_themes_on_parent_theme_id_and_child_theme_id (parent_theme_id,child_theme_id) UNIQUE +# diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index d9c9216c277..f2b056faea5 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -1,32 +1,36 @@ -require_dependency 'sass/discourse_stylesheets' require_dependency 'distributed_cache' class ColorScheme < ActiveRecord::Base - def self.themes + CUSTOM_SCHEMES = { + dark: { + "primary" => 'dddddd', + "secondary" => '222222', + "tertiary" => '0f82af', + "quaternary" => 'c14924', + "header_background" => '111111', + "header_primary" => '333333', + "highlight" => 'a87137', + "danger" => 'e45735', + "success" => '1ca551', + "love" => 'fa6c8d' + } + } + + def self.base_color_scheme_colors base_with_hash = {} base_colors.each do |name, color| - base_with_hash[name] = "##{color}" + base_with_hash[name] = "#{color}" end - [ - { id: 'default', colors: base_with_hash }, - { - id: 'dark', - colors: { - "primary" => '#dddddd', - "secondary" => '#222222', - "tertiary" => '#0f82af', - "quaternary" => '#c14924', - "header_background" => '#111111', - "header_primary" => '#333333', - "highlight" => '#a87137', - "danger" => '#e45735', - "success" => '#1ca551', - "love" => '#fa6c8d' - } - } + list = [ + { id: 'default', colors: base_with_hash } ] + + CUSTOM_SCHEMES.each do |k,v| + list.push({id: k.to_s, colors: v}) + end + list end def self.hex_cache @@ -39,9 +43,12 @@ class ColorScheme < ActiveRecord::Base alias_method :colors, :color_scheme_colors - scope :current_version, ->{ where(versioned_id: nil) } + before_save do + if self.id + self.version += 1 + end + end - after_destroy :destroy_versions after_save :publish_discourse_stylesheet after_save :dump_hex_cache after_destroy :dump_hex_cache @@ -64,13 +71,18 @@ class ColorScheme < ActiveRecord::Base @base_colors end - def self.enabled - current_version.find_by(enabled: true) + def self.base_color_schemes + base_color_scheme_colors.map do |hash| + scheme = new(name: I18n.t("color_schemes.#{hash[:id]}"), base_scheme_id: hash[:id]) + scheme.colors = hash[:colors].map{|k,v| {name: k.to_s, hex: v.sub("#","")}} + scheme.is_base = true + scheme + end end def self.base return @base_color_scheme if @base_color_scheme - @base_color_scheme = new(name: I18n.t('color_schemes.base_theme_name'), enabled: false) + @base_color_scheme = new(name: I18n.t('color_schemes.base_theme_name')) @base_color_scheme.colors = base_colors.map { |name, hex| {name: name, hex: hex} } @base_color_scheme.is_base = true @base_color_scheme @@ -101,7 +113,7 @@ class ColorScheme < ActiveRecord::Base end # Can't use `where` here because base doesn't allow it - (enabled || base).colors.find {|c| c.name == name }.try(:hex) || :nil + (base).colors.find {|c| c.name == name }.try(:hex) || :nil end def self.hex_for_name(name) @@ -129,17 +141,39 @@ class ColorScheme < ActiveRecord::Base end end - def previous_version - ColorScheme.where(versioned_id: self.id).where('version < ?', self.version).order('version DESC').first + def base_colors + colors = nil + if base_scheme_id && base_scheme_id != "default" + colors = CUSTOM_SCHEMES[base_scheme_id.to_sym] + end + colors || ColorScheme.base_colors end - def destroy_versions - ColorScheme.where(versioned_id: self.id).destroy_all + def resolved_colors + resolved = ColorScheme.base_colors.dup + if base_scheme_id && base_scheme_id != "default" + if scheme = CUSTOM_SCHEMES[base_scheme_id.to_sym] + scheme.each do |name, value| + resolved[name] = value + end + end + end + colors.each do |c| + resolved[c.name] = c.hex + end + resolved end def publish_discourse_stylesheet - MessageBus.publish("/discourse_stylesheet", self.name) - DiscourseStylesheets.cache.clear + if self.id + themes = Theme.where(color_scheme_id: self.id).to_a + if themes.present? + Stylesheet::Manager.cache.clear + themes.each do |theme| + theme.notify_scheme_change(_clear_manager_cache = false) + end + end + end end def dump_hex_cache @@ -152,13 +186,11 @@ end # # Table name: color_schemes # -# id :integer not null, primary key -# name :string not null -# enabled :boolean default(FALSE), not null -# versioned_id :integer -# version :integer default(1), not null -# created_at :datetime not null -# updated_at :datetime not null -# via_wizard :boolean default(FALSE), not null -# theme_id :string +# id :integer not null, primary key +# name :string not null +# version :integer default(1), not null +# created_at :datetime not null +# updated_at :datetime not null +# via_wizard :boolean default(FALSE), not null +# base_scheme_id :string # diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb new file mode 100644 index 00000000000..34df0657997 --- /dev/null +++ b/app/models/remote_theme.rb @@ -0,0 +1,85 @@ +require_dependency 'git_importer' + +class RemoteTheme < ActiveRecord::Base + has_one :theme + + def self.import_theme(url, user=Discourse.system_user) + importer = GitImporter.new(url) + importer.import! + + theme_info = JSON.parse(importer["about.json"]) + theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"]) + + remote_theme = new + theme.remote_theme = remote_theme + + remote_theme.remote_url = importer.url + remote_theme.update_from_remote(importer) + + theme.save! + theme + ensure + begin + importer.cleanup! + rescue => e + Rails.logger.warn("Failed cleanup remote git #{e}") + end + end + + def update_remote_version + importer = GitImporter.new(remote_url) + importer.import! + self.updated_at = Time.zone.now + self.remote_version, self.commits_behind = importer.commits_since(remote_version) + end + + def update_from_remote(importer=nil) + return unless remote_url + cleanup = false + unless importer + cleanup = true + importer = GitImporter.new(remote_url) + importer.import! + end + + Theme.targets.keys.each do |target| + Theme::ALLOWED_FIELDS.each do |field| + value = importer["#{target}/#{field=="scss"?"#{target}.scss":"#{field}.html"}"] + theme.set_field(target.to_sym, field, value) + end + end + + theme_info = JSON.parse(importer["about.json"]) + self.license_url ||= theme_info["license_url"] + self.about_url ||= theme_info["about_url"] + + self.remote_updated_at = Time.zone.now + self.remote_version = importer.version + self.local_version = importer.version + self.commits_behind = 0 + + self + ensure + begin + importer.cleanup! if cleanup + rescue => e + Rails.logger.warn("Failed cleanup remote git #{e}") + end + end +end + +# == Schema Information +# +# Table name: remote_themes +# +# id :integer not null, primary key +# remote_url :string not null +# remote_version :string +# local_version :string +# about_url :string +# license_url :string +# commits_behind :integer +# remote_updated_at :datetime +# created_at :datetime +# updated_at :datetime +# diff --git a/app/models/site_customization.rb b/app/models/site_customization.rb deleted file mode 100644 index 4b2e0ab988e..00000000000 --- a/app/models/site_customization.rb +++ /dev/null @@ -1,299 +0,0 @@ -require_dependency 'sass/discourse_sass_compiler' -require_dependency 'sass/discourse_stylesheets' -require_dependency 'distributed_cache' - -class SiteCustomization < ActiveRecord::Base - ENABLED_KEY = '7e202ef2-56d7-47d5-98d8-a9c8d15e57dd' - - COMPILER_VERSION = 4 - - @cache = DistributedCache.new('site_customization') - - def self.css_fields - %w(stylesheet mobile_stylesheet embedded_css) - end - - def self.html_fields - %w(body_tag head_tag header mobile_header footer mobile_footer) - end - - before_create do - self.enabled ||= false - self.key ||= SecureRandom.uuid - true - end - - def compile_stylesheet(scss) - DiscourseSassCompiler.compile("@import \"theme_variables\";\n" << scss, 'custom') - rescue => e - puts e.backtrace.join("\n") unless Sass::SyntaxError === e - raise e - end - - def transpile(es6_source, version) - template = Tilt::ES6ModuleTranspilerTemplate.new {} - wrapped = < { - #{es6_source} -}); -PLUGIN_API_JS - - template.babel_transpile(wrapped) - end - - def process_html(html) - doc = Nokogiri::HTML.fragment(html) - doc.css('script[type="text/x-handlebars"]').each do |node| - name = node["name"] || node["data-template-name"] || "broken" - is_raw = name =~ /\.raw$/ - if is_raw - template = "require('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})" - node.replace < - (function() { - Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template}; - })(); - -COMPILED - else - template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})" - node.replace < - (function() { - Ember.TEMPLATES[#{name.inspect}] = #{template}; - })(); - -COMPILED - end - - end - - doc.css('script[type="text/discourse-plugin"]').each do |node| - if node['version'].present? - begin - code = transpile(node.inner_html, node['version']) - node.replace("") - rescue MiniRacer::RuntimeError => ex - node.replace("") - end - end - end - - doc.to_s - end - - before_save do - SiteCustomization.html_fields.each do |html_attr| - if self.send("#{html_attr}_changed?") - self.send("#{html_attr}_baked=", process_html(self.send(html_attr))) - end - end - - SiteCustomization.css_fields.each do |stylesheet_attr| - if self.send("#{stylesheet_attr}_changed?") - begin - self.send("#{stylesheet_attr}_baked=", compile_stylesheet(self.send(stylesheet_attr))) - rescue Sass::SyntaxError => e - self.send("#{stylesheet_attr}_baked=", DiscourseSassCompiler.error_as_css(e, "custom stylesheet")) - end - end - end - end - - def any_stylesheet_changed? - SiteCustomization.css_fields.each do |fieldname| - return true if self.send("#{fieldname}_changed?") - end - false - end - - after_save do - remove_from_cache! - if any_stylesheet_changed? - MessageBus.publish "/file-change/#{key}", SecureRandom.hex - MessageBus.publish "/file-change/#{SiteCustomization::ENABLED_KEY}", SecureRandom.hex - end - MessageBus.publish "/header-change/#{key}", header if header_changed? - MessageBus.publish "/footer-change/#{key}", footer if footer_changed? - DiscourseStylesheets.cache.clear - end - - after_destroy do - remove_from_cache! - end - - def self.enabled_key - ENABLED_KEY.dup << RailsMultisite::ConnectionManagement.current_db - end - - def self.field_for_target(target=nil) - target ||= :desktop - - case target.to_sym - when :mobile then :mobile_stylesheet - when :desktop then :stylesheet - when :embedded then :embedded_css - end - end - - def self.baked_for_target(target=nil) - "#{field_for_target(target)}_baked".to_sym - end - - def self.enabled_stylesheet_contents(target=:desktop) - @cache["enabled_stylesheet_#{target}:#{COMPILER_VERSION}"] ||= where(enabled: true) - .order(:name) - .pluck(baked_for_target(target)) - .compact - .join("\n") - end - - def self.stylesheet_contents(key, target) - if key == ENABLED_KEY - enabled_stylesheet_contents(target) - else - where(key: key) - .pluck(baked_for_target(target)) - .first - end - end - - def self.custom_stylesheet(preview_style=nil, target=:desktop) - preview_style ||= ENABLED_KEY - if preview_style == ENABLED_KEY - stylesheet_link_tag(ENABLED_KEY, target, enabled_stylesheet_contents(target)) - else - lookup_field(preview_style, target, :stylesheet_link_tag) - end - end - - %i{header top footer head_tag body_tag}.each do |name| - define_singleton_method("custom_#{name}") do |preview_style=nil, target=:desktop| - preview_style ||= ENABLED_KEY - lookup_field(preview_style, target, name) - end - end - - def self.lookup_field(key, target, field) - return if key.blank? - - cache_key = "#{key}:#{target}:#{field}:#{COMPILER_VERSION}" - - lookup = @cache[cache_key] - return lookup.html_safe if lookup - - styles = if key == ENABLED_KEY - order(:name).where(enabled:true).to_a - else - [find_by(key: key)].compact - end - - val = if styles.present? - styles.map do |style| - lookup = target == :mobile ? "mobile_#{field}" : field - if html_fields.include?(lookup.to_s) - style.ensure_baked!(lookup) - style.send("#{lookup}_baked") - else - style.send(lookup) - end - end.compact.join("\n") - end - - (@cache[cache_key] = val || "").html_safe - end - - def self.remove_from_cache!(key, broadcast = true) - MessageBus.publish('/site_customization', key: key) if broadcast - clear_cache! - end - - def self.clear_cache! - @cache.clear - end - - def ensure_baked!(field) - - # If the version number changes, clear out all the baked fields - if compiler_version != COMPILER_VERSION - updates = { compiler_version: COMPILER_VERSION } - SiteCustomization.html_fields.each do |f| - updates["#{f}_baked".to_sym] = nil - end - - update_columns(updates) - end - - baked = send("#{field}_baked") - if baked.blank? - if val = self.send(field) - val = process_html(val) rescue "" - self.update_columns("#{field}_baked" => val) - end - end - end - - def remove_from_cache! - self.class.remove_from_cache!(self.class.enabled_key) - self.class.remove_from_cache!(key) - end - - def mobile_stylesheet_link_tag - stylesheet_link_tag(:mobile) - end - - def stylesheet_link_tag(target=:desktop) - content = self.send(SiteCustomization.field_for_target(target)) - SiteCustomization.stylesheet_link_tag(key, target, content) - end - - def self.stylesheet_link_tag(key, target, content) - return "" unless content.present? - - hash = Digest::MD5.hexdigest(content) - link_css_tag "/site_customizations/#{key}.css?target=#{target}&v=#{hash}" - end - - def self.link_css_tag(href) - href = (GlobalSetting.cdn_url || "") + "#{GlobalSetting.relative_url_root}#{href}&__ws=#{Discourse.current_hostname}" - %Q{}.html_safe - end -end - -# == Schema Information -# -# Table name: site_customizations -# -# id :integer not null, primary key -# name :string not null -# stylesheet :text -# header :text -# user_id :integer not null -# enabled :boolean not null -# key :string not null -# created_at :datetime not null -# updated_at :datetime not null -# stylesheet_baked :text default(""), not null -# mobile_stylesheet :text -# mobile_header :text -# mobile_stylesheet_baked :text -# footer :text -# mobile_footer :text -# head_tag :text -# body_tag :text -# top :text -# mobile_top :text -# embedded_css :text -# embedded_css_baked :text -# head_tag_baked :text -# body_tag_baked :text -# header_baked :text -# mobile_header_baked :text -# footer_baked :text -# mobile_footer_baked :text -# compiler_version :integer default(0), not null -# -# Indexes -# -# index_site_customizations_on_key (key) -# diff --git a/app/models/stylesheet_cache.rb b/app/models/stylesheet_cache.rb index c9dfa867623..2c568072cbf 100644 --- a/app/models/stylesheet_cache.rb +++ b/app/models/stylesheet_cache.rb @@ -3,11 +3,11 @@ class StylesheetCache < ActiveRecord::Base MAX_TO_KEEP = 50 - def self.add(target,digest,content) + def self.add(target,digest,content,source_map) return false if where(target: target, digest: digest).exists? - success = create(target: target, digest: digest, content: content) + success = create(target: target, digest: digest, content: content, source_map: source_map) count = StylesheetCache.count if count > MAX_TO_KEEP @@ -39,6 +39,8 @@ end # content :text not null # created_at :datetime # updated_at :datetime +# theme_id :integer default(-1), not null +# source_map :text # # Indexes # diff --git a/app/models/theme.rb b/app/models/theme.rb new file mode 100644 index 00000000000..32308c662fe --- /dev/null +++ b/app/models/theme.rb @@ -0,0 +1,256 @@ +require_dependency 'distributed_cache' +require_dependency 'stylesheet/compiler' +require_dependency 'stylesheet/manager' + +class Theme < ActiveRecord::Base + + ALLOWED_FIELDS = %w{scss head_tag header after_header body_tag footer} + + @cache = DistributedCache.new('theme') + + belongs_to :color_scheme + has_many :theme_fields, dependent: :destroy + has_many :child_theme_relation, class_name: 'ChildTheme', foreign_key: 'parent_theme_id', dependent: :destroy + has_many :child_themes, through: :child_theme_relation, source: :child_theme + belongs_to :remote_theme + + before_create do + self.key ||= SecureRandom.uuid + true + end + + after_save do + changed_fields.each(&:save!) + changed_fields.clear + + Theme.expire_site_cache! if user_selectable_changed? + + @dependant_themes = nil + @included_themes = nil + end + + after_save do + remove_from_cache! + notify_scheme_change if color_scheme_id_changed? + end + + after_destroy do + remove_from_cache! + if SiteSetting.default_theme_key == self.key + Theme.clear_default! + end + end + + after_commit ->(theme) do + theme.notify_theme_change + end, on: :update + + def self.expire_site_cache! + Site.clear_anon_cache! + ApplicationSerializer.expire_cache_fragment!("user_themes") + end + + def self.clear_default! + SiteSetting.default_theme_key = "" + expire_site_cache! + end + + def set_default! + SiteSetting.default_theme_key = key + Theme.expire_site_cache! + end + + def self.lookup_field(key, target, field) + return if key.blank? + + cache_key = "#{key}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}" + lookup = @cache[cache_key] + return lookup.html_safe if lookup + + target = target.to_sym + theme = find_by(key: key) + + val = theme.resolve_baked_field(target, field) if theme + + (@cache[cache_key] = val || "").html_safe + end + + def self.remove_from_cache!(themes=nil) + clear_cache! + end + + def self.clear_cache! + @cache.clear + end + + + def self.targets + @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2) + end + + + def notify_scheme_change(clear_manager_cache=true) + Stylesheet::Manager.cache.clear if clear_manager_cache + message = refresh_message_for_targets(["desktop", "mobile", "admin"], self.color_scheme_id, self, Rails.env.development?) + MessageBus.publish('/file-change', message) + end + + def notify_theme_change + Stylesheet::Manager.clear_theme_cache! + + themes = [self] + dependant_themes + + message = themes.map do |theme| + refresh_message_for_targets([:mobile_theme,:desktop_theme], theme.id, theme) + end.compact.flatten + MessageBus.publish('/file-change', message) + end + + def refresh_message_for_targets(targets, id, theme, add_cache_breaker=false) + targets.map do |target| + link = Stylesheet::Manager.stylesheet_link_tag(target.to_sym, 'all', theme.key) + if link + href = link.split(/["']/)[1] + if add_cache_breaker + href << (href.include?("?") ? "&" : "?") + href << SecureRandom.hex + end + { + name: "/stylesheets/#{target}#{id ? "_#{id}": ""}", + new_href: href + } + end + end + end + + def dependant_themes + @dependant_themes ||= resolve_dependant_themes(:up) + end + + def included_themes + @included_themes ||= resolve_dependant_themes(:down) + end + + def resolve_dependant_themes(direction) + + select_field,where_field=nil + + if direction == :up + select_field = "parent_theme_id" + where_field = "child_theme_id" + elsif direction == :down + select_field = "child_theme_id" + where_field = "parent_theme_id" + else + raise "Unknown direction" + end + + themes = [] + return [] unless id + + uniq = Set.new + uniq << id + + iterations = 0 + added = [id] + + while added.length > 0 && iterations < 5 + + iterations += 1 + + new_themes = Theme.where("id in (SELECT #{select_field} + FROM child_themes + WHERE #{where_field} in (?))", added).to_a + + added = [] + new_themes.each do |theme| + unless uniq.include?(theme.id) + added << theme.id + uniq << theme.id + themes << theme + end + end + + end + + themes + end + + def resolve_baked_field(target, name) + list_baked_fields(target,name).map{|f| f.value_baked || f.value}.join("\n") + end + + def list_baked_fields(target, name) + + target = target.to_sym + + theme_ids = [self.id] + (included_themes.map(&:id) || []) + fields = ThemeField.where(target: [Theme.targets[target], Theme.targets[:common]]) + .where(name: name.to_s) + .includes(:theme) + .joins("JOIN ( + SELECT #{theme_ids.map.with_index{|id,idx| "#{id} AS theme_id, #{idx} AS sort_column"}.join(" UNION ALL SELECT ")} + ) as X ON X.theme_id = theme_fields.theme_id") + .order('sort_column, target') + fields.each(&:ensure_baked!) + fields + end + + def remove_from_cache! + self.class.remove_from_cache! + end + + def changed_fields + @changed_fields ||= [] + end + + def set_field(target, name, value) + name = name.to_s + + target_id = Theme.targets[target.to_sym] + raise "Unknown target #{target} passed to set field" unless target_id + + field = theme_fields.find{|f| f.name==name && f.target == target_id} + if field + if value.blank? + field.destroy + else + if field.value != value + field.value = value + changed_fields << field + end + end + else + theme_fields.build(target: target_id, value: value, name: name) if value.present? + end + end + + def add_child_theme!(theme) + child_theme_relation.create!(child_theme_id: theme.id) + @included_themes = nil + child_themes.reload + save! + end +end + +# == Schema Information +# +# Table name: themes +# +# id :integer not null, primary key +# name :string not null +# user_id :integer not null +# key :string not null +# created_at :datetime not null +# updated_at :datetime not null +# compiler_version :integer default(0), not null +# user_selectable :boolean default(FALSE), not null +# hidden :boolean default(FALSE), not null +# color_scheme_id :integer +# remote_theme_id :integer +# +# Indexes +# +# index_themes_on_key (key) +# index_themes_on_remote_theme_id (remote_theme_id) UNIQUE +# diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb new file mode 100644 index 00000000000..ead11507aff --- /dev/null +++ b/app/models/theme_field.rb @@ -0,0 +1,117 @@ +class ThemeField < ActiveRecord::Base + + COMPILER_VERSION = 5 + + belongs_to :theme + + def transpile(es6_source, version) + template = Tilt::ES6ModuleTranspilerTemplate.new {} + wrapped = < { + #{es6_source} +}); +PLUGIN_API_JS + + template.babel_transpile(wrapped) + end + + def process_html(html) + doc = Nokogiri::HTML.fragment(html) + doc.css('script[type="text/x-handlebars"]').each do |node| + name = node["name"] || node["data-template-name"] || "broken" + is_raw = name =~ /\.raw$/ + if is_raw + template = "require('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})" + node.replace < + (function() { + Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template}; + })(); + +COMPILED + else + template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})" + node.replace < + (function() { + Ember.TEMPLATES[#{name.inspect}] = #{template}; + })(); + +COMPILED + end + + end + + doc.css('script[type="text/discourse-plugin"]').each do |node| + if node['version'].present? + begin + code = transpile(node.inner_html, node['version']) + node.replace("") + rescue MiniRacer::RuntimeError => ex + node.replace("") + end + end + end + + doc.to_s + end + + + def self.html_fields + %w(body_tag head_tag header footer after_header) + end + + + def ensure_baked! + if ThemeField.html_fields.include?(self.name) + if !self.value_baked || compiler_version != COMPILER_VERSION + + self.value_baked = process_html(self.value) + self.compiler_version = COMPILER_VERSION + + if self.value_baked_changed? || compiler_version.changed? + self.update_columns(value_baked: value_baked, compiler_version: compiler_version) + end + end + end + end + + def target_name + Theme.targets.invert[target].to_s + end + + before_save do + if value_changed? && !value_baked_changed? + self.value_baked = nil + end + end + + after_commit do + ensure_baked! + + Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss") + + # TODO message for mobile vs desktop + MessageBus.publish "/header-change/#{theme.key}", self.value if self.name == "header" + MessageBus.publish "/footer-change/#{theme.key}", self.value if self.name == "footer" + end +end + +# == Schema Information +# +# Table name: theme_fields +# +# id :integer not null, primary key +# theme_id :integer not null +# target :integer not null +# name :string not null +# value :text not null +# value_baked :text +# created_at :datetime +# updated_at :datetime +# compiler_version :integer default(0), not null +# +# Indexes +# +# index_theme_fields_on_theme_id_and_target_and_name (theme_id,target,name) UNIQUE +# diff --git a/app/models/user_history.rb b/app/models/user_history.rb index a6677de389d..e177cd8de4a 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -19,8 +19,8 @@ class UserHistory < ActiveRecord::Base @actions ||= Enum.new(delete_user: 1, change_trust_level: 2, change_site_setting: 3, - change_site_customization: 4, - delete_site_customization: 5, + change_theme: 4, + delete_theme: 5, checked_for_custom_avatar: 6, # not used anymore notified_about_avatar: 7, notified_about_sequential_replies: 8, @@ -71,8 +71,8 @@ class UserHistory < ActiveRecord::Base @staff_actions ||= [:delete_user, :change_trust_level, :change_site_setting, - :change_site_customization, - :delete_site_customization, + :change_theme, + :delete_theme, :change_site_text, :suspend_user, :unsuspend_user, @@ -158,7 +158,7 @@ class UserHistory < ActiveRecord::Base end def new_value_is_json? - [UserHistory.actions[:change_site_customization], UserHistory.actions[:delete_site_customization]].include?(action) + [UserHistory.actions[:change_theme], UserHistory.actions[:delete_theme]].include?(action) end def previous_value_is_json? diff --git a/app/serializers/color_scheme_color_serializer.rb b/app/serializers/color_scheme_color_serializer.rb index b1d3d809b69..3e99c06cf20 100644 --- a/app/serializers/color_scheme_color_serializer.rb +++ b/app/serializers/color_scheme_color_serializer.rb @@ -6,6 +6,11 @@ class ColorSchemeColorSerializer < ApplicationSerializer end def default_hex - ColorScheme.base_colors[object.name] + if object.color_scheme + object.color_scheme.base_colors[object.name] + else + # it is a base color so it is already default + object.hex + end end end diff --git a/app/serializers/color_scheme_serializer.rb b/app/serializers/color_scheme_serializer.rb index 965d5923778..2d5ab79d396 100644 --- a/app/serializers/color_scheme_serializer.rb +++ b/app/serializers/color_scheme_serializer.rb @@ -1,8 +1,4 @@ class ColorSchemeSerializer < ApplicationSerializer - attributes :id, :name, :enabled, :is_base + attributes :id, :name, :is_base, :base_scheme_id has_many :colors, serializer: ColorSchemeColorSerializer, embed: :objects - - def base - object.is_base || false - end end diff --git a/app/serializers/site_customization_serializer.rb b/app/serializers/site_customization_serializer.rb deleted file mode 100644 index 6a3e70ff211..00000000000 --- a/app/serializers/site_customization_serializer.rb +++ /dev/null @@ -1,7 +0,0 @@ -class SiteCustomizationSerializer < ApplicationSerializer - - attributes :id, :name, :key, :enabled, :created_at, :updated_at, - :stylesheet, :header, :footer, :top, - :mobile_stylesheet, :mobile_header, :mobile_footer, :mobile_top, - :head_tag, :body_tag, :embedded_css -end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index d43c165a96e..5ea12e8cbe7 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -24,13 +24,25 @@ class SiteSerializer < ApplicationSerializer :tags_filter_regexp, :top_tags, :wizard_required, - :topic_featured_link_allowed_category_ids + :topic_featured_link_allowed_category_ids, + :user_themes has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :trust_levels, embed: :objects has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serialzer: UserFieldSerializer + def user_themes + cache_fragment("user_themes") do + Theme.where('key = :default OR user_selectable', + default: SiteSetting.default_theme_key) + .order(:name) + .pluck(:key, :name) + .map{|k,n| {theme_key: k, name: n, default: k == SiteSetting.default_theme_key}} + .as_json + end + end + def groups cache_fragment("group_names") do Group.order(:name).pluck(:id,:name).map { |id,name| { id: id, name: name } }.as_json diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb new file mode 100644 index 00000000000..051cf5d2f6d --- /dev/null +++ b/app/serializers/theme_serializer.rb @@ -0,0 +1,42 @@ +class ThemeFieldSerializer < ApplicationSerializer + attributes :name, :target, :value + + def target + case object.target + when 0 then "common" + when 1 then "desktop" + when 2 then "mobile" + end + end +end + +class ChildThemeSerializer < ApplicationSerializer + attributes :id, :name, :key, :created_at, :updated_at, :default + + def include_default? + object.key == SiteSetting.default_theme_key + end + + def default + true + end +end + +class RemoteThemeSerializer < ApplicationSerializer + attributes :id, :remote_url, :remote_version, :local_version, :about_url, + :license_url, :commits_behind, :remote_updated_at, :updated_at + + # wow, AMS has some pretty nutty logic where it tries to find the path here + # from action dispatch, tell it not to + def about_url + object.about_url + end +end + +class ThemeSerializer < ChildThemeSerializer + attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id + + has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects + has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects + has_one :remote_theme, serializer: RemoteThemeSerializer, embed: :objects +end diff --git a/app/serializers/user_history_serializer.rb b/app/serializers/user_history_serializer.rb index 039e25c61d0..f73d3e424b2 100644 --- a/app/serializers/user_history_serializer.rb +++ b/app/serializers/user_history_serializer.rb @@ -12,7 +12,8 @@ class UserHistorySerializer < ApplicationSerializer :post_id, :category_id, :action, - :custom_type + :custom_type, + :id has_one :acting_user, serializer: BasicUserSerializer, embed: :objects has_one :target_user, serializer: BasicUserSerializer, embed: :objects diff --git a/app/services/color_scheme_revisor.rb b/app/services/color_scheme_revisor.rb index 5db7bb79d1e..ce66c37332d 100644 --- a/app/services/color_scheme_revisor.rb +++ b/app/services/color_scheme_revisor.rb @@ -9,63 +9,26 @@ class ColorSchemeRevisor self.new(color_scheme, params).revise end - def self.revert(color_scheme) - self.new(color_scheme).revert - end - def revise ColorScheme.transaction do - if @params[:enabled] - ColorScheme.where('id != ?', @color_scheme.id).update_all enabled: false - end @color_scheme.name = @params[:name] if @params.has_key?(:name) - @color_scheme.enabled = @params[:enabled] if @params.has_key?(:enabled) - @color_scheme.theme_id = @params[:theme_id] if @params.has_key?(:theme_id) - new_version = false + @color_scheme.base_scheme_id = @params[:base_scheme_id] if @params.has_key?(:base_scheme_id) + has_colors = @params[:colors] - if @params[:colors] - new_version = @params[:colors].any? do |c| - (existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex] - end - end - - if new_version - ColorScheme.create( - name: @color_scheme.name, - enabled: false, - colors: @color_scheme.colors_hashes, - versioned_id: @color_scheme.id, - version: @color_scheme.version) - @color_scheme.version += 1 - end - - if @params[:colors] + if has_colors @params[:colors].each do |c| if existing = @color_scheme.colors_by_name[c[:name]] existing.update_attributes(c) + else + @color_scheme.color_scheme_colors << ColorSchemeColor.new(name: c[:name], hex: c[:hex]) end end - end - - @color_scheme.save - @color_scheme.clear_colors_cache - end - @color_scheme - end - - def revert - ColorScheme.transaction do - if prev = @color_scheme.previous_version - @color_scheme.version = prev.version - @color_scheme.colors.clear - prev.colors.update_all(color_scheme_id: @color_scheme.id) - prev.destroy - @color_scheme.save! @color_scheme.clear_colors_cache end - end + @color_scheme.save if has_colors || @color_scheme.name_changed? || @color_scheme.base_scheme_id_changed? + end @color_scheme end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 71734ab2e20..1cd09176af4 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -113,34 +113,49 @@ class StaffActionLogger })) end - SITE_CUSTOMIZATION_LOGGED_ATTRS = [ - 'stylesheet', 'mobile_stylesheet', - 'header', 'mobile_header', - 'top', 'mobile_top', - 'footer', 'mobile_footer', - 'head_tag', - 'body_tag', - 'position', - 'enabled', - 'key' - ] + def theme_json(theme) + ThemeSerializer.new(theme, root:false).to_json + end + + def strip_duplicates(old,cur) + return [old,cur] unless old && cur + + old = JSON.parse(old) + cur = JSON.parse(cur) + + old.each do |k, v| + next if k == "name" + next if k == "id" + if (v == cur[k]) + cur.delete(k) + old.delete(k) + end + end + + [old.to_json, cur.to_json] + end + + def log_theme_change(old_json, new_theme, opts={}) + raise Discourse::InvalidParameters.new(:new_theme) unless new_theme + + new_json = theme_json(new_theme) + + old_json,new_json = strip_duplicates(old_json,new_json) - def log_site_customization_change(old_record, site_customization_params, opts={}) - raise Discourse::InvalidParameters.new(:site_customization_params) unless site_customization_params UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:change_site_customization], - subject: site_customization_params[:name], - previous_value: old_record ? old_record.attributes.slice(*SITE_CUSTOMIZATION_LOGGED_ATTRS).to_json : nil, - new_value: site_customization_params.slice(*(SITE_CUSTOMIZATION_LOGGED_ATTRS.map(&:to_sym))).to_json + action: UserHistory.actions[:change_theme], + subject: new_theme.name, + previous_value: old_json, + new_value: new_json })) end - def log_site_customization_destroy(site_customization, opts={}) - raise Discourse::InvalidParameters.new(:site_customization) unless site_customization + def log_theme_destroy(theme, opts={}) + raise Discourse::InvalidParameters.new(:theme) unless theme UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:delete_site_customization], - subject: site_customization.name, - previous_value: site_customization.attributes.slice(*SITE_CUSTOMIZATION_LOGGED_ATTRS).to_json + action: UserHistory.actions[:delete_theme], + subject: theme.name, + previous_value: theme_json(theme) })) end diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index 702bbd9f029..044ce2fc767 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -1,13 +1,13 @@ <%- if rtl? %> - <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %> + <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %> <%- else %> - <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> + <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> <%- end %> <%- if staff? %> - <%= DiscourseStylesheets.stylesheet_link_tag(:admin) %> + <%= discourse_stylesheet_link_tag(:admin) %> <%- end %> -<%- unless customization_disabled? %> - <%= SiteCustomization.custom_stylesheet(session[:preview_style], mobile_view? ? :mobile : :desktop) %> +<%- if theme_key %> + <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 41b321f22d3..59100bee029 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -4,6 +4,7 @@ <%= content_for?(:title) ? yield(:title) + ' - ' + SiteSetting.title : SiteSetting.title %> + <%= render partial: "layouts/head" %> <%= render partial: "common/special_font_face" %> <%= render partial: "common/discourse_stylesheet" %> @@ -41,7 +42,7 @@ <%- end %> <%- unless customization_disabled? %> - <%= raw SiteCustomization.custom_head_tag(session[:preview_style]) %> + <%= raw theme_lookup("head_tag") %> <%- end %> <%= render_google_universal_analytics_code %> @@ -82,7 +83,7 @@ <%- unless customization_disabled? || loading_admin? %> - <%= SiteCustomization.custom_header(session[:preview_style], mobile_view? ? :mobile : :desktop) %> + <%= theme_lookup("header") %> <%- end %>
    @@ -118,7 +119,7 @@ <%= render_google_analytics_code %> <%- unless customization_disabled? %> - <%= raw SiteCustomization.custom_body_tag(session[:preview_style]) %> + <%= raw theme_lookup("body_tag") %> <%- end %> diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index e4e7f9a47e8..ac757cc8c24 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -6,20 +6,16 @@ <%= render partial: "layouts/head" %> <%- if rtl? %> - <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %> + <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_rtl : :desktop_rtl) %> <%- else %> - <%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> - <%- end %> - <%- unless customization_disabled? %> - <%= raw SiteCustomization.custom_head_tag(session[:preview_style]) %> + <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> <%- end %> + <%= theme_lookup("head_tag") %> <%= render_google_universal_analytics_code %> <%= yield :head %> - <%- unless customization_disabled? %> - <%= SiteCustomization.custom_header(session[:preview_style], mobile_view? ? :mobile : :desktop) %> - <%- end %> + <%= theme_lookup("header") %>
    ">
    @@ -37,9 +33,7 @@

    <%= t 'powered_by_html' %>

    <%= render_google_analytics_code %> - <%- unless customization_disabled? %> - <%= raw SiteCustomization.custom_body_tag(session[:preview_style]) %> - <%- end %> + <%= theme_lookup("body_tag") %> <%= yield :after_body %> diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb index b4ee7cfa7c2..61fa3ff86d4 100644 --- a/app/views/layouts/embed.html.erb +++ b/app/views/layouts/embed.html.erb @@ -5,7 +5,7 @@ <%= stylesheet_link_tag 'embed' %> <%- unless customization_disabled? %> - <%= SiteCustomization.custom_stylesheet(session[:preview_style], :embedded) %> + <%= Theme.custom_stylesheet(session[:preview_style], :embedded) %> <%- end %> <%= javascript_include_tag 'break_string' %> diff --git a/app/views/layouts/no_ember.html.erb b/app/views/layouts/no_ember.html.erb index 2c6b3f74beb..035a84027bc 100644 --- a/app/views/layouts/no_ember.html.erb +++ b/app/views/layouts/no_ember.html.erb @@ -9,23 +9,17 @@ <%= render partial: "common/discourse_stylesheet" %> <%= discourse_csrf_tags %> - <%- unless customization_disabled? %> - <%= raw SiteCustomization.custom_head_tag(session[:preview_style]) %> - <%- end %> + <%= theme_lookup("head_tag") %> <%= yield(:no_ember_head) %> class="<%= @custom_body_class %>"<% end %>> - <%- unless customization_disabled? %> - <%= SiteCustomization.custom_header(session[:preview_style], mobile_view? ? :mobile : :desktop) %> - <%- end %> + <%= theme_lookup("header") %>
    <%= render partial: 'header' %>
    <%= yield %>
    - <%- unless customization_disabled? %> - <%= SiteCustomization.custom_footer(session[:preview_style], mobile_view? ? :mobile : :desktop) %> - <%- end %> + <%= theme_lookup("footer") %> diff --git a/app/views/wizard/index.html.erb b/app/views/wizard/index.html.erb index a0f193526f6..f0a0c0d46ff 100644 --- a/app/views/wizard/index.html.erb +++ b/app/views/wizard/index.html.erb @@ -1,6 +1,6 @@ - <%= stylesheet_link_tag 'wizard' %> + <%= discourse_stylesheet_link_tag 'wizard', theme_key: nil %> <%= script 'ember_jquery' %> <%= script 'wizard-vendor' %> <%= script 'wizard-application' %> diff --git a/config/application.rb b/config/application.rb index 258ca0c276b..ec216260ea0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -78,11 +78,12 @@ module Discourse path =~ /assets\/images/ && !%w(.js .css).include?(File.extname(filename)) end] - config.assets.precompile += ['vendor.js', 'common.css', 'desktop.css', 'mobile.css', - 'admin.js', 'admin.css', 'shiny/shiny.css', 'preload-store.js.es6', - 'browser-update.js', 'embed.css', 'break_string.js', 'ember_jquery.js', - 'pretty-text-bundle.js', 'wizard.css', 'wizard-application.js', - 'wizard-vendor.js', 'plugin.js', 'plugin-third-party.js'] + config.assets.precompile += %w{ + vendor.js admin.js preload-store.js.es6 + browser-update.js break_string.js ember_jquery.js + pretty-text-bundle.js wizard-application.js + wizard-vendor.js plugin.js plugin-third-party.js + } # Precompile all available locales Dir.glob("#{config.root}/app/assets/javascripts/locales/*.js.erb").each do |file| @@ -169,6 +170,8 @@ module Discourse config.relative_url_root = GlobalSetting.relative_url_root end + require_dependency 'stylesheet/manager' + config.after_initialize do # require common dependencies that are often required by plugins # in the past observers would load them as side-effects diff --git a/config/environments/development.rb b/config/environments/development.rb index a422173a670..bc345547ab8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -29,7 +29,6 @@ Discourse::Application.configure do config.active_record.migration_error = :page_load config.watchable_dirs['lib'] = [:rb] - config.sass.debug_info = false config.handlebars.precompile = false # we recommend you use mailcatcher https://github.com/sj26/mailcatcher @@ -49,6 +48,13 @@ Discourse::Application.configure do config.enable_anon_caching = false require 'rbtrace' + + require 'stylesheet/watcher' + if defined? Puma + STDERR.puts "Staring CSS change watcher" + @watcher = Stylesheet::Watcher.watch + end + if emails = GlobalSetting.developer_emails config.developer_emails = emails.split(",").map(&:downcase).map(&:strip) end diff --git a/config/environments/production.rb b/config/environments/production.rb index f73b6e2dc9b..b4331f15ac6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -14,8 +14,6 @@ Discourse::Application.configure do config.assets.js_compressor = :uglifier - config.assets.css_compressor = :sass - # stuff should be pre-compiled config.assets.compile = false diff --git a/config/initializers/100-sprockets.rb b/config/initializers/100-sprockets.rb deleted file mode 100644 index f6556e220df..00000000000 --- a/config/initializers/100-sprockets.rb +++ /dev/null @@ -1,19 +0,0 @@ -require_dependency 'sass/discourse_stylesheets' -require_dependency 'sass/discourse_sass_importer' -require_dependency 'sass/discourse_safe_sass_importer' - -DiscourseSassTemplate = Class.new(Sass::Rails::SassTemplate) do - def importer_class - DiscourseSassImporter - end -end -DiscourseScssTemplate = Class.new(DiscourseSassTemplate) do - def syntax - :scss - end -end - -Rails.application.config.assets.configure do |env| - env.register_engine '.sass', DiscourseSassTemplate - env.register_engine '.scss', DiscourseScssTemplate -end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 378a3d2855a..95c82c25645 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -174,6 +174,9 @@ en: bootstrap_mode_enabled: "To make launching your new site easier, you are in bootstrap mode. All new users will be granted trust level 1 and have daily email digest updates enabled. This will be automatically turned off when total user count exceeds %{min_users} users." bootstrap_mode_disabled: "Bootstrap mode will be disabled in next 24 hours." + themes: + default_description: "Default" + s3: regions: us_east_1: "US East (N. Virginia)" @@ -634,6 +637,7 @@ en: revoke_access: "Revoke Access" undo_revoke_access: "Undo Revoke Access" api_approved: "Approved:" + theme: "Theme" staff_counters: flags_given: "helpful flags" @@ -2780,32 +2784,14 @@ en: customize: title: "Customize" long_title: "Site Customizations" - css: "CSS" - header: "Header" - top: "Top" - footer: "Footer" - embedded_css: "Embedded CSS" - head_tag: - text: "" - title: "HTML that will be inserted before the tag" - body_tag: - text: "" - title: "HTML that will be inserted before the tag" - override_default: "Do not include standard style sheet" - enabled: "Enabled?" preview: "preview" - undo_preview: "remove preview" - rescue_preview: "default style" explain_preview: "See the site with this custom stylesheet" - explain_undo_preview: "Go back to the currently enabled custom stylesheet" - explain_rescue_preview: "See the site with the default stylesheet" save: "Save" new: "New" new_style: "New Style" import: "Import" - import_title: "Select a file or paste text" delete: "Delete" - delete_confirm: "Delete this customization?" + delete_confirm: "Delete this theme?" about: "Modify CSS stylesheets and HTML headers on the site. Add a customization to start." color: "Color" opacity: "Opacity" @@ -2819,13 +2805,67 @@ en: revert: "Revert Changes" revert_confirm: "Are you sure you want to revert your changes?" - css_html: - title: "CSS/HTML" - long_title: "CSS and HTML Customizations" + theme: + import_theme: "Import Theme" + customize_desc: "Customize:" + title: "Themes" + long_title: "Amend colors, CSS and HTML contents of your site" + edit: "Edit" + edit_confirm: "This is a remote theme, if you edit CSS/HTML your changes will be erased next time you update the theme." + common: "Common" + desktop: "Desktop" + mobile: "Mobile" + is_default: "Theme is enabled by default" + user_selectable: "Theme can be selected by users" + color_scheme: "Color Scheme" + color_scheme_select: "Select colors to be used by theme" + custom_sections: "Custom sections:" + included_themes: "Included Themes" + child_themes_check: "Theme includes other child themes" + css_html: "Custom CSS/HTML" + edit_css_html: "Edit CSS/HTML" + edit_css_html_help: "You have not edited any CSS or HTML" + import_web_tip: "Repository containing theme" + import_file_tip: ".dcstyle.json file containing theme" + about_theme: "About Theme" + license: "License" + update_to_latest: "Update to Latest" + check_for_updates: "Check for Updates" + updating: "Updating..." + up_to_date: "Theme is up-to-date, last checked:" + add: "Add" + commits_behind: + one: "Theme is 1 commit behind!" + other: "Theme is {{count}} commit behind!" + scss: + text: "CSS" + title: "Enter custom CSS, we accept all valid CSS and SCSS styles" + header: + text: "Header" + title: "Enter HTML to display above site header" + after_header: + text: "After Header" + title: "Enter HTML to display on all pages after header" + footer: + text: "Footer" + title: "Enter HTML to display on page footer" + embedded_scss: + text: "Embedded CSS" + title: "Enter custom CSS to deliver with embedded version of comments" + head_tag: + text: "" + title: "HTML that will be inserted before the tag" + body_tag: + text: "" + title: "HTML that will be inserted before the tag" colors: + select_base: + title: "Select base color scheme" + description: "Base scheme:" title: "Colors" + edit: "Edit Color Schemes" long_title: "Color Schemes" - about: "Modify the colors used on the site without writing CSS. Add a scheme to start." + about: "Modify the colors used by your themes. Create a new color scheme to start." new_name: "New Color Scheme" copy_name_prefix: "Copy of" delete_confirm: "Delete this color scheme?" @@ -2966,8 +3006,8 @@ en: change_trust_level: "change trust level" change_username: "change username" change_site_setting: "change site setting" - change_site_customization: "change site customization" - delete_site_customization: "delete site customization" + change_theme: "change theme" + delete_theme: "delete theme" change_site_text: "change site text" suspend_user: "suspend user" unsuspend_user: "unsuspend user" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2fe259ab239..1ff8f33edbf 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2763,6 +2763,8 @@ en: color_schemes: base_theme_name: "Base" + default: "Light Scheme" + dark: "Dark Scheme" about: "About" guidelines: "Guidelines" diff --git a/config/routes.rb b/config/routes.rb index e6c3d9ed719..c534d2d41ec 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,8 +56,6 @@ Discourse::Application.routes.draw do get "site/basic-info" => 'site#basic_info' get "site/statistics" => 'site#statistics' - get "site_customizations/:key" => "site_customizations#show" - get "srv/status" => "forums#status" get "wizard" => "wizard#index" @@ -162,6 +160,7 @@ Discourse::Application.routes.draw do scope "/logs" do resources :staff_action_logs, only: [:index] + get 'staff_action_logs/:id/diff' => 'staff_action_logs#diff' resources :screened_emails, only: [:index, :destroy] resources :screened_ip_addresses, only: [:index, :create, :update, :destroy] do collection do @@ -174,9 +173,9 @@ Discourse::Application.routes.draw do get "/logs" => "staff_action_logs#index" get "customize" => "color_schemes#index", constraints: AdminConstraint.new - get "customize/css_html" => "site_customizations#index", constraints: AdminConstraint.new - get "customize/css_html/:id/:section" => "site_customizations#index", constraints: AdminConstraint.new + get "customize/themes" => "themes#index", constraints: AdminConstraint.new get "customize/colors" => "color_schemes#index", constraints: AdminConstraint.new + get "customize/colors/:id" => "color_schemes#index", constraints: AdminConstraint.new get "customize/permalinks" => "permalinks#index", constraints: AdminConstraint.new get "customize/embedding" => "embedding#show", constraints: AdminConstraint.new put "customize/embedding" => "embedding#update", constraints: AdminConstraint.new @@ -186,12 +185,17 @@ Discourse::Application.routes.draw do post "flags/agree/:id" => "flags#agree" post "flags/disagree/:id" => "flags#disagree" post "flags/defer/:id" => "flags#defer" - resources :site_customizations, constraints: AdminConstraint.new + + resources :themes, constraints: AdminConstraint.new + post "themes/import" => "themes#import" scope "/customize", constraints: AdminConstraint.new do resources :user_fields, constraints: AdminConstraint.new resources :emojis, constraints: AdminConstraint.new + get 'themes/:id/:target/:field_name/edit' => 'themes#index' + get 'themes/:id' => 'themes#index' + # They have periods in their URLs often: get 'site_texts' => 'site_texts#index' get 'site_texts/(:id)' => 'site_texts#show', constraints: { id: /[\w.\-]+/i } @@ -385,7 +389,8 @@ Discourse::Application.routes.draw do get "highlight-js/:hostname/:version.js" => "highlight_js#show", format: false, constraints: { hostname: /[\w\.-]+/ } - get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[a-z0-9_]+/ } + get "stylesheets/:name.css.map" => "stylesheets#show_source_map", constraints: { name: /[-a-z0-9_]+/ } + get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } post "uploads" => "uploads#create" @@ -708,6 +713,8 @@ Discourse::Application.routes.draw do get "/safe-mode" => "safe_mode#index" post "/safe-mode" => "safe_mode#enter", as: "safe_mode_enter" + get "/themes/assets/:key" => "themes#assets" + get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new end diff --git a/config/site_settings.yml b/config/site_settings.yml index 146b0ebed2a..71221c04ba4 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -184,6 +184,8 @@ basic: enable_mobile_theme: client: true default: true + default_theme_key: + hidden: true relative_date_duration: client: true default: 30 diff --git a/db/migrate/20170313192741_add_themes.rb b/db/migrate/20170313192741_add_themes.rb new file mode 100644 index 00000000000..ab2e3abc3b4 --- /dev/null +++ b/db/migrate/20170313192741_add_themes.rb @@ -0,0 +1,79 @@ +class AddThemes < ActiveRecord::Migration + def up + rename_table :site_customizations, :themes + + add_column :themes, :user_selectable, :bool, null: false, default: false + add_column :themes, :hidden, :bool, null: false, default: false + add_column :themes, :color_scheme_id, :integer + + create_table :child_themes do |t| + t.integer :parent_theme_id + t.integer :child_theme_id + t.timestamps + end + + add_index :child_themes, [:parent_theme_id, :child_theme_id], unique: true + add_index :child_themes, [:child_theme_id, :parent_theme_id], unique: true + + # versioning in color scheme table was very confusing, remove it + execute "DELETE FROM color_schemes WHERE versioned_id IS NOT NULL" + remove_column :color_schemes, :versioned_id + + enabled_theme_count = execute("SELECT count(*) FROM themes WHERE enabled") + .to_a[0]["count"].to_i + + + enabled_scheme_id = execute("SELECT id FROM color_schemes WHERE enabled") + .to_a[0]&.fetch("id") + + theme_key, theme_id = + execute("SELECT key, id FROM themes WHERE enabled").to_a[0]&.values + + if (enabled_theme_count == 0 && enabled_scheme_id) || enabled_theme_count > 1 + + puts "Creating a new default theme!" + + theme_key = '7e202ef2-6666-47d5-98d8-a9c8d15e57dd' + + sql = < 1 + execute < 0) + puts "Setting default theme" + sql = < 0 +SQL + remove_column :themes, value + end + + %w{ head_tag_baked + body_tag_baked + header_baked + footer_baked + mobile_footer_baked + mobile_header_baked + }.each do |col| + remove_column :themes, col + end + end +end diff --git a/db/migrate/20170328203122_add_compiler_version_to_theme_fields.rb b/db/migrate/20170328203122_add_compiler_version_to_theme_fields.rb new file mode 100644 index 00000000000..7f66ff7eae1 --- /dev/null +++ b/db/migrate/20170328203122_add_compiler_version_to_theme_fields.rb @@ -0,0 +1,5 @@ +class AddCompilerVersionToThemeFields < ActiveRecord::Migration + def change + add_column :theme_fields, :compiler_version, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20170407154510_rename_theme_id.rb b/db/migrate/20170407154510_rename_theme_id.rb new file mode 100644 index 00000000000..48d3524f64f --- /dev/null +++ b/db/migrate/20170407154510_rename_theme_id.rb @@ -0,0 +1,5 @@ +class RenameThemeId < ActiveRecord::Migration + def change + rename_column :color_schemes, :theme_id, :base_scheme_id + end +end diff --git a/db/migrate/20170410170923_add_theme_remote_fields.rb b/db/migrate/20170410170923_add_theme_remote_fields.rb new file mode 100644 index 00000000000..95cb5d7e5c0 --- /dev/null +++ b/db/migrate/20170410170923_add_theme_remote_fields.rb @@ -0,0 +1,17 @@ +class AddThemeRemoteFields < ActiveRecord::Migration + def change + create_table :remote_themes do |t| + t.string :remote_url, null: false + t.string :remote_version + t.string :local_version + t.string :about_url + t.string :license_url + t.integer :commits_behind + t.datetime :remote_updated_at + t.timestamps + end + + add_column :themes, :remote_theme_id, :integer + add_index :themes, :remote_theme_id, unique: true + end +end diff --git a/lib/autospec/manager.rb b/lib/autospec/manager.rb index 4505dd905f0..4833f22be31 100644 --- a/lib/autospec/manager.rb +++ b/lib/autospec/manager.rb @@ -222,12 +222,14 @@ class Autospec::Manager end end end + # special watcher for styles/templates - Autospec::ReloadCss::WATCHERS.each do |k, _| - matches = [] - matches << file if k.match(file) - Autospec::ReloadCss.run_on_change(matches) if matches.present? - end + # now handled via libass integration + # Autospec::ReloadCss::WATCHERS.each do |k, _| + # matches = [] + # matches << file if k.match(file) + # Autospec::ReloadCss.run_on_change(matches) if matches.present? + # end end queue_specs(specs) if hit diff --git a/lib/freedom_patches/resolve.rb b/lib/freedom_patches/resolve.rb deleted file mode 100644 index f4fdb619c70..00000000000 --- a/lib/freedom_patches/resolve.rb +++ /dev/null @@ -1,19 +0,0 @@ -# sass-rails expects an actual file to exists when calling `@import`. However, -# we don't actually create the files for our special imports but rather inject -# them dynamically. -module Discourse - module Sprockets - module Resolve - def resolve(path, options = {}) - return [path, []] if DiscourseSassImporter.special_imports.has_key?(File.basename(path, '.scss')) - super - end - end - end -end - -module Sprockets - class Base - prepend Discourse::Sprockets::Resolve - end -end diff --git a/lib/git_importer.rb b/lib/git_importer.rb new file mode 100644 index 00000000000..9ad1b15c6f2 --- /dev/null +++ b/lib/git_importer.rb @@ -0,0 +1,49 @@ +class GitImporter + + attr_reader :url + + def initialize(url) + @url = url + if @url.start_with?("https://github.com") && !@url.end_with?(".git") + @url += ".git" + end + @temp_folder = "#{Dir.tmpdir}/discourse_theme_#{SecureRandom.hex}" + end + + def import! + Discourse::Utils.execute_command("git", "clone", @url, @temp_folder) + end + + def commits_since(hash) + commit_hash, commits_behind = nil + + Dir.chdir(@temp_folder) do + commit_hash = Discourse::Utils.execute_command("git", "rev-parse", "HEAD").strip + commits_behind = Discourse::Utils.execute_command("git", "rev-list", "#{hash}..HEAD", "--count").strip + end + + [commit_hash, commits_behind] + end + + def version + Dir.chdir(@temp_folder) do + Discourse::Utils.execute_command("git", "rev-parse", "HEAD").strip + end + end + + def cleanup! + FileUtils.rm_rf(@temp_folder) + end + + def [](value) + fullpath = "#{@temp_folder}/#{value}" + return nil unless File.exist?(fullpath) + + # careful to handle symlinks here, don't want to expose random data + fullpath = Pathname.new(fullpath).realpath.to_s + if fullpath && fullpath.start_with?(@temp_folder) + File.read(fullpath) + end + end + +end diff --git a/lib/middleware/turbo_dev.rb b/lib/middleware/turbo_dev.rb index d3b7eb3e1b4..beda1652243 100644 --- a/lib/middleware/turbo_dev.rb +++ b/lib/middleware/turbo_dev.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true module Middleware # Cheat and bypass Rails in development mode if the client attempts to download a static asset diff --git a/lib/sass/discourse_safe_sass_importer.rb b/lib/sass/discourse_safe_sass_importer.rb deleted file mode 100644 index 54ee6c5d243..00000000000 --- a/lib/sass/discourse_safe_sass_importer.rb +++ /dev/null @@ -1,32 +0,0 @@ -require_dependency 'sass/discourse_sass_importer' - -# This custom importer is used to import stylesheets but excludes plugins and theming. -# It's used as a fallback when compilation of stylesheets fails. - -class DiscourseSafeSassImporter < DiscourseSassImporter - def special_imports - super.merge({ - "plugins" => [], - "plugins_mobile" => [], - "plugins_desktop" => [], - "plugins_variables" => [] - }) - end - - def find(name, options) - if name == "theme_variables" - # Load the default variables - contents = "" - special_imports[name].each do |css_file| - contents << File.read(css_file) - end - ::Sass::Engine.new(contents, options.merge( - filename: "#{name}.scss", - importer: self, - syntax: :scss - )) - else - super(name, options) - end - end -end diff --git a/lib/sass/discourse_sass_compiler.rb b/lib/sass/discourse_sass_compiler.rb deleted file mode 100644 index a9c6c3e5b30..00000000000 --- a/lib/sass/discourse_sass_compiler.rb +++ /dev/null @@ -1,85 +0,0 @@ -require_dependency 'sass/discourse_sass_importer' -require 'pathname' - -module Sass::Script::Functions - def _error(message) - raise Sass::SyntaxError, mesage - end -end - -class DiscourseSassCompiler - - def self.compile(scss, target, opts={}) - self.new(scss, target).compile(opts) - end - - # Takes a Sass::SyntaxError and generates css that will show the - # error at the bottom of the page. - def self.error_as_css(sass_error, label) - error = sass_error.sass_backtrace_str(label) - error.gsub!("\n", '\A ') - error.gsub!("'", '\27 ') - - "footer { white-space: pre; } - footer:after { content: '#{error}' }" - end - - - def initialize(scss, target) - @scss = scss - @target = target - - unless Sass::Script::Functions < Sprockets::SassFunctions - Sass::Script::Functions.send :include, Sprockets::SassFunctions - end - end - - # Compiles the given scss and output the css as a string. - # - # Options: - # safe: (boolean) if true, theme and plugin stylesheets will not be included. Default is false. - def compile(opts={}) - app = Rails.application - env = app.assets || Sprockets::Railtie.build_environment(app) - - pathname = Pathname.new("app/assets/stylesheets/#{@target}.scss") - - context = env.context_class.new( - environment: env, - filename: "#{@target}.scss", - pathname: pathname, - metadata: {} - ) - - debug_opts = Rails.env.production? ? {} : { - line_numbers: true, - # debug_info: true, # great with Firebug + FireSass, but not helpful elsewhere - style: :expanded - } - - importer_class = opts[:safe] ? DiscourseSafeSassImporter : DiscourseSassImporter - - css = ::Sass::Engine.new(@scss, { - syntax: :scss, - cache: false, - read_cache: false, - style: :compressed, - filesystem_importer: importer_class, - load_paths: context.environment.paths.map { |path| importer_class.new(path.to_s) }, - sprockets: { - context: context, - environment: context.environment - } - }.merge(debug_opts)).render - - css_output = css - if opts[:rtl] - begin - require 'r2' - css_output = R2.r2(css) if defined?(R2) - rescue; end - end - css_output - end - -end diff --git a/lib/sass/discourse_sass_importer.rb b/lib/sass/discourse_sass_importer.rb deleted file mode 100644 index 62230db937f..00000000000 --- a/lib/sass/discourse_sass_importer.rb +++ /dev/null @@ -1,100 +0,0 @@ -# This custom importer is used for site customizations. This is similar to the -# Sprockets::SassImporter implementation provided in sass-rails since that is used -# during asset precompilation. -class DiscourseSassImporter < Sass::Importers::Filesystem - module Sass - def extensions - { - 'css' => :scss, - 'css.scss' => :scss, - 'css.sass' => :sass, - 'css.erb' => :scss, - 'scss.erb' => :scss, - 'sass.erb' => :sass, - 'css.scss.erb' => :scss, - 'css.sass.erb' => :sass - }.merge!(super) - end - - def special_imports - { - "plugins" => DiscoursePluginRegistry.stylesheets, - "plugins_mobile" => DiscoursePluginRegistry.mobile_stylesheets, - "plugins_desktop" => DiscoursePluginRegistry.desktop_stylesheets, - "plugins_variables" => DiscoursePluginRegistry.sass_variables, - "theme_variables" => [ColorScheme::BASE_COLORS_FILE], - "category_backgrounds" => Proc.new { |c| "body.category-#{c.full_slug} { background-image: url(#{apply_cdn(c.uploaded_background.url)}) }\n" } - } - end - - def find_relative(name, base, options) - engine_from_path(name, File.dirname(base), options) - end - - def apply_cdn(url) - "#{GlobalSetting.cdn_url}#{url}" - end - - def find(name, options) - - if special_imports.has_key? name - case name - when "theme_variables" - contents = "" - ColorScheme.base_colors.each do |n, base_hex| - hex_val = ColorScheme.hex_for_name(n) || base_hex - contents << "$#{n}: ##{hex_val} !default;\n" - end - when "category_backgrounds" - contents = "" - Category.where('uploaded_background_id IS NOT NULL').each do |c| - contents << special_imports[name].call(c) if c.uploaded_background - end - else - stylesheets = special_imports[name] - contents = "" - stylesheets.each do |css_file| - if css_file =~ /\.scss$/ - contents << "@import '#{css_file}';" - else - contents << File.read(css_file) - end - depend_on(css_file) - end - end - - ::Sass::Engine.new(contents, options.merge( - filename: "#{name}.scss", - importer: self, - syntax: :scss - )) - else - engine_from_path(name, root, options) - end - end - - private - - def depend_on(filename) - if @context - @context.depend_on(filename) - @context.depend_on(globbed_file_parent(filename)) - end - end - - def engine_from_path(name, dir, options) - full_filename, _ = ::Sass::Util.destructure(find_real_file(dir, name, options)) - return unless full_filename && File.readable?(full_filename) - - depend_on(full_filename) - ::Sass::Engine.for_file(full_filename, options) - end - end - - include Sass - include ::Sass::Rails::SassImporter::Globbing - - def self.special_imports - self.new('').special_imports - end -end diff --git a/lib/sass/discourse_stylesheets.rb b/lib/sass/discourse_stylesheets.rb deleted file mode 100644 index cce67137e19..00000000000 --- a/lib/sass/discourse_stylesheets.rb +++ /dev/null @@ -1,178 +0,0 @@ -require_dependency 'sass/discourse_sass_compiler' -require_dependency 'distributed_cache' - -class DiscourseStylesheets - - CACHE_PATH ||= 'tmp/stylesheet-cache' - MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}" - MANIFEST_FULL_PATH ||= "#{MANIFEST_DIR}/stylesheet-manifest" - - @lock = Mutex.new - - def self.cache - return {} if Rails.env.development? - @cache ||= DistributedCache.new("discourse_stylesheet") - end - - def self.stylesheet_link_tag(target = :desktop, media = 'all') - - tag = cache[target] - - return tag.dup.html_safe if tag - - @lock.synchronize do - builder = self.new(target) - builder.compile unless File.exists?(builder.stylesheet_fullpath) - builder.ensure_digestless_file - tag = %[] - - cache[target] = tag - - tag.dup.html_safe - end - end - - def self.compile(target = :desktop, opts={}) - @lock.synchronize do - FileUtils.rm(MANIFEST_FULL_PATH, force: true) if opts[:force] - builder = self.new(target) - builder.compile(opts) - builder.stylesheet_filename - end - end - - def self.last_file_updated - if Rails.env.production? - @last_file_updated ||= if File.exists?(MANIFEST_FULL_PATH) - File.readlines(MANIFEST_FULL_PATH, 'r')[0] - else - mtime = max_file_mtime - FileUtils.mkdir_p(MANIFEST_DIR) - File.open(MANIFEST_FULL_PATH, "w") { |f| f.print(mtime) } - mtime - end - else - max_file_mtime - end - end - - def self.max_file_mtime - globs = ["#{Rails.root}/app/assets/stylesheets/**/*.*css"] - - Discourse.plugins.map { |plugin| File.dirname(plugin.path) }.each do |path| - globs += [ - "#{path}/plugin.rb", - "#{path}/**/*.*css", - ] - end - - globs.map do |pattern| - Dir.glob(pattern).map { |x| File.mtime(x) }.max - end.compact.max.to_i - end - - def initialize(target = :desktop) - @target = target - end - - def compile(opts={}) - unless opts[:force] - if File.exists?(stylesheet_fullpath) - unless StylesheetCache.where(target: @target, digest: digest).exists? - begin - StylesheetCache.add(@target, digest, File.read(stylesheet_fullpath)) - rescue => e - Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" - end - end - return true - end - end - - scss = File.read("#{Rails.root}/app/assets/stylesheets/#{@target}.scss") - rtl = @target.to_s =~ /_rtl$/ - css = begin - DiscourseSassCompiler.compile(scss, @target, rtl: rtl) - rescue Sass::SyntaxError => e - Rails.logger.error "Stylesheet failed to compile for '#{@target}'! Recompiling without plugins and theming." - Rails.logger.error e.sass_backtrace_str("#{@target} stylesheet") - DiscourseSassCompiler.compile(scss + DiscourseSassCompiler.error_as_css(e, "#{@target} stylesheet"), @target, safe: true) - end - FileUtils.mkdir_p(cache_fullpath) - File.open(stylesheet_fullpath, "w") do |f| - f.puts css - end - begin - StylesheetCache.add(@target, digest, css) - rescue => e - Rails.logger.warn "Completely unexpected error adding item to cache #{e}" - end - css - end - - def ensure_digestless_file - # file without digest is only for auto-reloading css in dev env - unless Rails.env.production? || (File.exist?(stylesheet_fullpath_no_digest) && File.mtime(stylesheet_fullpath) == File.mtime(stylesheet_fullpath_no_digest)) - FileUtils.cp(stylesheet_fullpath, stylesheet_fullpath_no_digest) - end - end - - def self.cache_fullpath - "#{Rails.root}/#{CACHE_PATH}" - end - - def cache_fullpath - self.class.cache_fullpath - end - - def stylesheet_fullpath - "#{cache_fullpath}/#{stylesheet_filename}" - end - def stylesheet_fullpath_no_digest - "#{cache_fullpath}/#{stylesheet_filename_no_digest}" - end - - def stylesheet_cdnpath - "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{Discourse.current_hostname}" - end - - def root_path - "#{GlobalSetting.relative_url_root}/" - end - - # using uploads cause we already have all the routing in place - def stylesheet_relpath - "#{root_path}stylesheets/#{stylesheet_filename}" - end - - def stylesheet_relpath_no_digest - "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" - end - - def stylesheet_filename - "#{@target}_#{digest}.css" - end - def stylesheet_filename_no_digest - "#{@target}.css" - end - - # digest encodes the things that trigger a recompile - def digest - @digest ||= begin - theme = (cs = ColorScheme.enabled) ? "#{cs.id}-#{cs.version}" : false - category_updated = Category.where("uploaded_background_id IS NOT NULL").last_updated_at - - if theme || category_updated > 0 - Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{theme}-#{DiscourseStylesheets.last_file_updated}-#{category_updated}" - else - digest_string = "defaults-#{DiscourseStylesheets.last_file_updated}" - - if cdn_url = GlobalSetting.cdn_url - digest_string = "#{digest_string}-#{cdn_url}" - end - - Digest::SHA1.hexdigest digest_string - end - end - end -end diff --git a/lib/stylesheet/common.rb b/lib/stylesheet/common.rb new file mode 100644 index 00000000000..e133e7cd2d0 --- /dev/null +++ b/lib/stylesheet/common.rb @@ -0,0 +1,5 @@ +require 'sassc' + +module Stylesheet + ASSET_ROOT = "#{Rails.root}/app/assets/stylesheets" unless defined? ASSET_ROOT +end diff --git a/lib/stylesheet/compiler.rb b/lib/stylesheet/compiler.rb new file mode 100644 index 00000000000..7ddf1066898 --- /dev/null +++ b/lib/stylesheet/compiler.rb @@ -0,0 +1,60 @@ +require_dependency 'stylesheet/common' +require_dependency 'stylesheet/importer' +require_dependency 'stylesheet/functions' + +module Stylesheet + + class Compiler + + def self.error_as_css(error, label) + error = error.message + error.gsub!("\n", '\A ') + error.gsub!("'", '\27 ') + + "footer { white-space: pre; } + footer:after { content: '#{error}' }" + end + + def self.compile_asset(asset, options={}) + + if Importer.special_imports[asset.to_s] + filename = "theme.scss" + file = "@import \"#{asset}\";" + else + filename = "#{asset}.scss" + path = "#{ASSET_ROOT}/#{filename}" + file = File.read path + end + + compile(file,filename,options) + + end + + def self.compile(stylesheet, filename, options={}) + + + source_map_file = options[:source_map_file] || "#{filename.sub(".scss","")}.css.map"; + engine = SassC::Engine.new(stylesheet, + importer: Importer, + filename: filename, + style: :compressed, + source_map_file: source_map_file, + source_map_contents: true, + theme_id: options[:theme_id], + load_paths: [ASSET_ROOT]) + + + result = engine.render + + if options[:rtl] + require 'r2' + [R2.r2(result), nil] + else + source_map = engine.source_map + source_map.force_encoding("UTF-8") + + [result, source_map] + end + end + end +end diff --git a/lib/stylesheet/functions.rb b/lib/stylesheet/functions.rb new file mode 100644 index 00000000000..cd21bb9a9c8 --- /dev/null +++ b/lib/stylesheet/functions.rb @@ -0,0 +1,9 @@ +module Stylesheet + module ScssFunctions + def asset_url(path) + SassC::Script::String.new("url('#{ActionController::Base.helpers.asset_path(path.value)}')") + end + end +end + +::SassC::Script::Functions.send :include, Stylesheet::ScssFunctions diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb new file mode 100644 index 00000000000..9d6824f38c9 --- /dev/null +++ b/lib/stylesheet/importer.rb @@ -0,0 +1,126 @@ +require_dependency 'stylesheet/common' + +module Stylesheet + class Importer < SassC::Importer + + @special_imports = {} + + def self.special_imports + @special_imports + end + + def self.register_import(name, &blk) + @special_imports[name] = blk + end + + register_import "plugins" do + import_files(DiscoursePluginRegistry.stylesheets) + end + + register_import "plugins_mobile" do + import_files(DiscoursePluginRegistry.mobile_stylesheets) + end + + register_import "plugins_desktop" do + import_files(DiscoursePluginRegistry.desktop_stylesheets) + end + + register_import "plugins_variables" do + import_files(DiscoursePluginRegistry.sass_variables) + end + + register_import "theme_variables" do + contents = "" + colors = (@theme_id && theme.color_scheme) ? theme.color_scheme.resolved_colors : ColorScheme.base_colors + colors.each do |n, hex| + contents << "$#{n}: ##{hex} !default;\n" + end + Import.new("theme_variable.scss", source: contents) + end + + register_import "category_backgrounds" do + contents = "" + Category.where('uploaded_background_id IS NOT NULL').each do |c| + contents << category_css(c) if c.uploaded_background + end + + Import.new("categoy_background.scss", source: contents) + end + + register_import "embedded_theme" do + next unless @theme_id + + theme_import(:common, :embedded_scss) + end + + register_import "mobile_theme" do + next unless @theme_id + + theme_import(:mobile, :scss) + end + + register_import "desktop_theme" do + next unless @theme_id + + theme_import(:desktop, :scss) + end + + def initialize(options) + @theme_id = options[:theme_id] + end + + def import_files(files) + files.map do |file| + # we never want inline css imports, they are a mess + # this tricks libsass so it imports inline instead + if file =~ /\.css$/ + file = file[0..-5] + end + Import.new(file) + end + end + + def theme_import(target, attr) + fields = theme.list_baked_fields(target, attr) + + fields.map do |field| + value = field.value + if value.present? + filename = "#{field.theme.id}/#{field.target_name}-#{field.name}-#{field.theme.name.parameterize}.scss" + with_comment = <] + cache[cache_key] = tag + + tag.dup.html_safe + end + end + + def self.precompile_css + themes = Theme.where('user_selectable OR key = ?', SiteSetting.default_theme_key).pluck(:key,:name) + themes << nil + themes.each do |key,name| + [:desktop, :mobile, :desktop_rtl, :mobile_rtl].each do |target| + STDERR.puts "precompile target: #{target} #{name}" + stylesheet_link_tag(target, nil, key) + end + end + nil + end + + def self.last_file_updated + if Rails.env.production? + @last_file_updated ||= if File.exists?(MANIFEST_FULL_PATH) + File.readlines(MANIFEST_FULL_PATH, 'r')[0] + else + mtime = max_file_mtime + FileUtils.mkdir_p(MANIFEST_DIR) + File.open(MANIFEST_FULL_PATH, "w") { |f| f.print(mtime) } + mtime + end + else + max_file_mtime + end + end + + def self.max_file_mtime + globs = ["#{Rails.root}/app/assets/stylesheets/**/*.*css"] + + Discourse.plugins.map { |plugin| File.dirname(plugin.path) }.each do |path| + globs += [ + "#{path}/plugin.rb", + "#{path}/**/*.*css", + ] + end + + globs.map do |pattern| + Dir.glob(pattern).map { |x| File.mtime(x) }.max + end.compact.max.to_i + end + + def initialize(target = :desktop, theme_key) + @target = target + @theme_key = theme_key + end + + def compile(opts={}) + unless opts[:force] + if File.exists?(stylesheet_fullpath) + unless StylesheetCache.where(target: qualified_target, digest: digest).exists? + begin + source_map = File.read(source_map_fullpath) rescue nil + StylesheetCache.add(qualified_target, digest, File.read(stylesheet_fullpath), source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" + end + end + return true + end + end + + rtl = @target.to_s =~ /_rtl$/ + css,source_map = begin + Stylesheet::Compiler.compile_asset( + @target, + rtl: rtl, + theme_id: theme&.id, + source_map_file: source_map_filename + ) + rescue SassC::SyntaxError => e + Rails.logger.error "Failed to compile #{@target} stylesheet: #{e.message}" + [Stylesheet::Compiler.error_as_css(e, "#{@target} stylesheet"), nil] + end + + FileUtils.mkdir_p(cache_fullpath) + + File.open(stylesheet_fullpath, "w") do |f| + f.puts css + end + + if source_map.present? + File.open(source_map_fullpath, "w") do |f| + f.puts source_map + end + end + + begin + StylesheetCache.add(qualified_target, digest, css, source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding item to cache #{e}" + end + css + end + + def ensure_digestless_file + # file without digest is only for auto-reloading css in dev env + unless Rails.env.production? || (File.exist?(stylesheet_fullpath_no_digest) && File.mtime(stylesheet_fullpath) == File.mtime(stylesheet_fullpath_no_digest)) + FileUtils.cp(stylesheet_fullpath, stylesheet_fullpath_no_digest) + end + end + + def self.cache_fullpath + "#{Rails.root}/#{CACHE_PATH}" + end + + def cache_fullpath + self.class.cache_fullpath + end + + def stylesheet_fullpath + "#{cache_fullpath}/#{stylesheet_filename}" + end + + def source_map_fullpath + "#{cache_fullpath}/#{source_map_filename}" + end + + def source_map_filename + "#{stylesheet_filename}.map" + end + + def stylesheet_fullpath_no_digest + "#{cache_fullpath}/#{stylesheet_filename_no_digest}" + end + + def stylesheet_cdnpath + "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{Discourse.current_hostname}" + end + + def stylesheet_path + if Rails.env.development? + if @target.to_s =~ /theme/ + stylesheet_relpath + else + stylesheet_relpath_no_digest + end + else + stylesheet_cdnpath + end + end + + def root_path + "#{GlobalSetting.relative_url_root}/" + end + + def stylesheet_relpath + "#{root_path}stylesheets/#{stylesheet_filename}" + end + + def stylesheet_relpath_no_digest + "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" + end + + def qualified_target + if is_theme? + "#{@target}_#{theme.id}" + else + scheme_string = theme && theme.color_scheme ? "_#{theme.color_scheme.id}" : "" + "#{@target}#{scheme_string}" + end + end + + def stylesheet_filename(with_digest = true) + digest_string = "_#{self.digest}" if with_digest + "#{qualified_target}#{digest_string}.css" + end + + def stylesheet_filename_no_digest + stylesheet_filename(_with_digest=false) + end + + def is_theme? + !!(@target.to_s =~ /_theme$/) + end + + # digest encodes the things that trigger a recompile + def digest + @digest ||= begin + if is_theme? + theme_digest + else + color_scheme_digest + end + end + end + + def theme + @theme ||= (Theme.find_by(key: @theme_key) || :nil) + @theme == :nil ? nil : @theme + end + + def theme_digest + scss = "" + + if [:mobile_theme, :desktop_theme].include?(@target) + scss = theme.resolve_baked_field(:common, :scss) + scss += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) + elsif @target == :embedded_theme + scss = theme.resolve_baked_field(:common, :embedded_scss) + else + raise "attempting to look up theme digest for invalid field" + end + + Digest::SHA1.hexdigest scss.to_s + end + + def color_scheme_digest + + cs = theme&.color_scheme + category_updated = Category.where("uploaded_background_id IS NOT NULL").last_updated_at + + if cs || category_updated > 0 + Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{Stylesheet::Manager.last_file_updated}-#{category_updated}" + else + digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}" + + if cdn_url = GlobalSetting.cdn_url + digest_string = "#{digest_string}-#{cdn_url}" + end + + Digest::SHA1.hexdigest digest_string + end + end +end diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb new file mode 100644 index 00000000000..8f2005e74b7 --- /dev/null +++ b/lib/stylesheet/watcher.rb @@ -0,0 +1,70 @@ +require 'listen' + +module Stylesheet + class Watcher + + def self.watch(paths=nil) + watcher = new(paths) + watcher.start + watcher + end + + def initialize(paths) + @paths = paths || ["app/assets/stylesheets", "plugins"] + @queue = Queue.new + end + + def start + + Thread.new do + begin + while true + worker_loop + end + rescue => e + STDERR.puts "CSS change notifier crashed #{e}" + end + end + + + root = Rails.root.to_s + @paths.each do |watch| + Thread.new do + begin + Listen.to("#{root}/#{watch}") do |modified, added, _| + paths = [modified, added].flatten + paths.compact! + paths.map!{|long| long[(root.length+1)..-1]} + process_change(paths) + end + rescue => e + STDERR.puts "Failed to listen for CSS changes at: #{watch}\n#{e}" + end + end + end + end + + def worker_loop + @queue.pop + while @queue.length > 0 + @queue.pop + end + + message = ["desktop", "mobile", "admin"].map do |name| + {hash: SecureRandom.hex, name: "/stylesheets/#{name}.css"} + end + + Stylesheet::Manager.cache.clear + MessageBus.publish '/file-change', message + end + + def process_change(paths) + paths.each do |path| + if path =~ /\.(css|scss)$/ + @queue.push path + end + end + end + + end +end diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake index 23e036f57c0..16a6e403ae3 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -50,10 +50,8 @@ task 'assets:precompile:css' => 'environment' do # css will get precompiled during first request instead in that case. if ActiveRecord::Base.connection.table_exists?(ColorScheme.table_name) - STDERR.puts "Compiling css for #{db}" - [:desktop, :mobile, :desktop_rtl, :mobile_rtl].each do |target| - STDERR.puts "target: #{target} #{DiscourseStylesheets.compile(target)}" - end + STDERR.puts "Compiling css for #{db} #{Time.zone.now}" + Stylesheet::Manager.precompile_css end end diff --git a/lib/wizard/builder.rb b/lib/wizard/builder.rb index 17de742274e..3fc48b8313a 100644 --- a/lib/wizard/builder.rb +++ b/lib/wizard/builder.rb @@ -114,28 +114,31 @@ class Wizard end @wizard.append_step('colors') do |step| - theme_id = ColorScheme.where(via_wizard: true).pluck(:theme_id) - theme_id = theme_id.present? ? theme_id[0] : 'default' + scheme_id = ColorScheme.where(via_wizard: true).pluck(:base_scheme_id)&.first + scheme_id ||= 'default' - themes = step.add_field(id: 'theme_id', type: 'dropdown', required: true, value: theme_id) - ColorScheme.themes.each {|t| themes.add_choice(t[:id], data: t) } + themes = step.add_field(id: 'base_scheme_id', type: 'dropdown', required: true, value: scheme_id) + ColorScheme.base_color_scheme_colors.each do |t| + with_hash = t[:colors].dup + with_hash.map{|k,v| with_hash[k] = "##{v}"} + themes.add_choice(t[:id], data: {colors: with_hash}) + end step.add_field(id: 'theme_preview', type: 'component') step.on_update do |updater| - scheme_name = updater.fields[:theme_id] + scheme_name = updater.fields[:base_scheme_id] - theme = ColorScheme.themes.find {|s| s[:id] == scheme_name } + theme = ColorScheme.base_color_schemes.find{|s| s.base_scheme_id == scheme_name} colors = [] - theme[:colors].each do |name, hex| - colors << {name: name, hex: hex[1..-1] } + theme.colors.each do |color| + colors << {name: color.name, hex: color.hex } end attrs = { - enabled: true, name: I18n.t("wizard.step.colors.fields.theme_id.choices.#{scheme_name}.label"), colors: colors, - theme_id: scheme_name + base_scheme_id: scheme_name } scheme = ColorScheme.where(via_wizard: true).first @@ -148,6 +151,14 @@ class Wizard scheme = ColorScheme.new(attrs) scheme.save! end + + default_theme = Theme.find_by(key: SiteSetting.default_theme_key) + unless default_theme + default_theme = Theme.new(name: "Default Theme", user_id: -1) + end + default_theme.color_scheme_id = scheme.id + default_theme.save! + SiteSetting.default_theme_key = default_theme.key end end diff --git a/public/javascripts/spectrum.css b/public/javascripts/spectrum.css new file mode 100644 index 00000000000..a8ad9e4f82f --- /dev/null +++ b/public/javascripts/spectrum.css @@ -0,0 +1,507 @@ +/*** +Spectrum Colorpicker v1.8.0 +https://github.com/bgrins/spectrum +Author: Brian Grinstead +License: MIT +***/ + +.sp-container { + position:absolute; + top:0; + left:0; + display:inline-block; + *display: inline; + *zoom: 1; + /* https://github.com/bgrins/spectrum/issues/40 */ + z-index: 9999994; + overflow: hidden; +} +.sp-container.sp-flat { + position: relative; +} + +/* Fix for * { box-sizing: border-box; } */ +.sp-container, +.sp-container * { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */ +.sp-top { + position:relative; + width: 100%; + display:inline-block; +} +.sp-top-inner { + position:absolute; + top:0; + left:0; + bottom:0; + right:0; +} +.sp-color { + position: absolute; + top:0; + left:0; + bottom:0; + right:20%; +} +.sp-hue { + position: absolute; + top:0; + right:0; + bottom:0; + left:84%; + height: 100%; +} + +.sp-clear-enabled .sp-hue { + top:33px; + height: 77.5%; +} + +.sp-fill { + padding-top: 80%; +} +.sp-sat, .sp-val { + position: absolute; + top:0; + left:0; + right:0; + bottom:0; +} + +.sp-alpha-enabled .sp-top { + margin-bottom: 18px; +} +.sp-alpha-enabled .sp-alpha { + display: block; +} +.sp-alpha-handle { + position:absolute; + top:-4px; + bottom: -4px; + width: 6px; + left: 50%; + cursor: pointer; + border: 1px solid black; + background: white; + opacity: .8; +} +.sp-alpha { + display: none; + position: absolute; + bottom: -14px; + right: 0; + left: 0; + height: 8px; +} +.sp-alpha-inner { + border: solid 1px #333; +} + +.sp-clear { + display: none; +} + +.sp-clear.sp-clear-display { + background-position: center; +} + +.sp-clear-enabled .sp-clear { + display: block; + position:absolute; + top:0px; + right:0; + bottom:0; + left:84%; + height: 28px; +} + +/* Don't allow text selection */ +.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button { + -webkit-user-select:none; + -moz-user-select: -moz-none; + -o-user-select:none; + user-select: none; +} + +.sp-container.sp-input-disabled .sp-input-container { + display: none; +} +.sp-container.sp-buttons-disabled .sp-button-container { + display: none; +} +.sp-container.sp-palette-buttons-disabled .sp-palette-button-container { + display: none; +} +.sp-palette-only .sp-picker-container { + display: none; +} +.sp-palette-disabled .sp-palette-container { + display: none; +} + +.sp-initial-disabled .sp-initial { + display: none; +} + + +/* Gradients for hue, saturation and value instead of images. Not pretty... but it works */ +.sp-sat { + background-image: -webkit-gradient(linear, 0 0, 100% 0, from(#FFF), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(left, #FFF, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)"; + filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81'); +} +.sp-val { + background-image: -webkit-gradient(linear, 0 100%, 0 0, from(#000000), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(bottom, #000000, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)"; + filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000'); +} + +.sp-hue { + background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000)); + background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); +} + +/* IE filters do not support multiple color stops. + Generate 6 divs, line them up, and do two color gradients for each. + Yes, really. + */ +.sp-1 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00'); +} +.sp-2 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00'); +} +.sp-3 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff'); +} +.sp-4 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff'); +} +.sp-5 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff'); +} +.sp-6 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000'); +} + +.sp-hidden { + display: none !important; +} + +/* Clearfix hack */ +.sp-cf:before, .sp-cf:after { content: ""; display: table; } +.sp-cf:after { clear: both; } +.sp-cf { *zoom: 1; } + +/* Mobile devices, make hue slider bigger so it is easier to slide */ +@media (max-device-width: 480px) { + .sp-color { right: 40%; } + .sp-hue { left: 63%; } + .sp-fill { padding-top: 60%; } +} +.sp-dragger { + border-radius: 5px; + height: 5px; + width: 5px; + border: 1px solid #fff; + background: #000; + cursor: pointer; + position:absolute; + top:0; + left: 0; +} +.sp-slider { + position: absolute; + top:0; + cursor:pointer; + height: 3px; + left: -1px; + right: -1px; + border: 1px solid #000; + background: white; + opacity: .8; +} + +/* +Theme authors: +Here are the basic themeable display options (colors, fonts, global widths). +See http://bgrins.github.io/spectrum/themes/ for instructions. +*/ + +.sp-container { + border-radius: 0; + background-color: #ECECEC; + border: solid 1px #f0c49B; + padding: 0; +} +.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear { + font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; +} +.sp-top { + margin-bottom: 3px; +} +.sp-color, .sp-hue, .sp-clear { + border: solid 1px #666; +} + +/* Input */ +.sp-input-container { + float:right; + width: 100px; + margin-bottom: 4px; +} +.sp-initial-disabled .sp-input-container { + width: 100%; +} +.sp-input { + font-size: 12px !important; + border: 1px inset; + padding: 4px 5px; + margin: 0; + width: 100%; + background:transparent; + border-radius: 3px; + color: #222; +} +.sp-input:focus { + border: 1px solid orange; +} +.sp-input.sp-validation-error { + border: 1px solid red; + background: #fdd; +} +.sp-picker-container , .sp-palette-container { + float:left; + position: relative; + padding: 10px; + padding-bottom: 300px; + margin-bottom: -290px; +} +.sp-picker-container { + width: 172px; + border-left: solid 1px #fff; +} + +/* Palettes */ +.sp-palette-container { + border-right: solid 1px #ccc; +} + +.sp-palette-only .sp-palette-container { + border: 0; +} + +.sp-palette .sp-thumb-el { + display: block; + position:relative; + float:left; + width: 24px; + height: 15px; + margin: 3px; + cursor: pointer; + border:solid 2px transparent; +} +.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active { + border-color: orange; +} +.sp-thumb-el { + position:relative; +} + +/* Initial */ +.sp-initial { + float: left; + border: solid 1px #333; +} +.sp-initial span { + width: 30px; + height: 25px; + border:none; + display:block; + float:left; + margin:0; +} + +.sp-initial .sp-clear-display { + background-position: center; +} + +/* Buttons */ +.sp-palette-button-container, +.sp-button-container { + float: right; +} + +/* Replacer (the little preview div that shows up instead of the ) */ +.sp-replacer { + margin:0; + overflow:hidden; + cursor:pointer; + padding: 4px; + display:inline-block; + *zoom: 1; + *display: inline; + border: solid 1px #91765d; + background: #eee; + color: #333; + vertical-align: middle; +} +.sp-replacer:hover, .sp-replacer.sp-active { + border-color: #F0C49B; + color: #111; +} +.sp-replacer.sp-disabled { + cursor:default; + border-color: silver; + color: silver; +} +.sp-dd { + padding: 2px 0; + height: 16px; + line-height: 16px; + float:left; + font-size:10px; +} +.sp-preview { + position:relative; + width:25px; + height: 20px; + border: solid 1px #222; + margin-right: 5px; + float:left; + z-index: 0; +} + +.sp-palette { + *width: 220px; + max-width: 220px; +} +.sp-palette .sp-thumb-el { + width:16px; + height: 16px; + margin:2px 1px; + border: solid 1px #d0d0d0; +} + +.sp-container { + padding-bottom:0; +} + + +/* Buttons: http://hellohappy.org/css3-buttons/ */ +.sp-container button { + background-color: #eeeeee; + background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc); + background-image: -moz-linear-gradient(top, #eeeeee, #cccccc); + background-image: -ms-linear-gradient(top, #eeeeee, #cccccc); + background-image: -o-linear-gradient(top, #eeeeee, #cccccc); + background-image: linear-gradient(to bottom, #eeeeee, #cccccc); + border: 1px solid #ccc; + border-bottom: 1px solid #bbb; + border-radius: 3px; + color: #333; + font-size: 14px; + line-height: 1; + padding: 5px 4px; + text-align: center; + text-shadow: 0 1px 0 #eee; + vertical-align: middle; +} +.sp-container button:hover { + background-color: #dddddd; + background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -o-linear-gradient(top, #dddddd, #bbbbbb); + background-image: linear-gradient(to bottom, #dddddd, #bbbbbb); + border: 1px solid #bbb; + border-bottom: 1px solid #999; + cursor: pointer; + text-shadow: 0 1px 0 #ddd; +} +.sp-container button:active { + border: 1px solid #aaa; + border-bottom: 1px solid #888; + -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; +} +.sp-cancel { + font-size: 11px; + color: #d93f3f !important; + margin:0; + padding:2px; + margin-right: 5px; + vertical-align: middle; + text-decoration:none; + +} +.sp-cancel:hover { + color: #d93f3f !important; + text-decoration: underline; +} + + +.sp-palette span:hover, .sp-palette span.sp-thumb-active { + border-color: #000; +} + +.sp-preview, .sp-alpha, .sp-thumb-el { + position:relative; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); +} +.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner { + display:block; + position:absolute; + top:0;left:0;bottom:0;right:0; +} + +.sp-palette .sp-thumb-inner { + background-position: 50% 50%; + background-repeat: no-repeat; +} + +.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=); +} + +.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=); +} + +.sp-clear-display { + background-repeat:no-repeat; + background-position: center; + background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==); +} diff --git a/public/javascripts/spectrum.js b/public/javascripts/spectrum.js new file mode 100644 index 00000000000..2b31452841d --- /dev/null +++ b/public/javascripts/spectrum.js @@ -0,0 +1,2 @@ +(function(factory){"use strict";if(typeof define==="function"&&define.amd){define(["jquery"],factory)}else if(typeof exports=="object"&&typeof module=="object"){module.exports=factory(require("jquery"))}else{factory(jQuery)}})(function($,undefined){"use strict";var defaultOpts={beforeShow:noop,move:noop,change:noop,show:noop,hide:noop,color:false,flat:false,showInput:false,allowEmpty:false,showButtons:true,clickoutFiresChange:true,showInitial:false,showPalette:false,showPaletteOnly:false,hideAfterPaletteSelect:false,togglePaletteOnly:false,showSelectionPalette:true,localStorageKey:false,appendTo:"body",maxSelectionSize:7,cancelText:"cancel",chooseText:"choose",togglePaletteMoreText:"more",togglePaletteLessText:"less",clearText:"Clear Color Selection",noColorSelectedText:"No Color Selected",preferredFormat:false,className:"",containerClassName:"",replacerClassName:"",showAlpha:false,theme:"sp-light",palette:[["#ffffff","#000000","#ff0000","#ff8000","#ffff00","#008000","#0000ff","#4b0082","#9400d3"]],selectionPalette:[],disabled:false,offset:null},spectrums=[],IE=!!/msie/i.exec(window.navigator.userAgent),rgbaSupport=function(){function contains(str,substr){return!!~(""+str).indexOf(substr)}var elem=document.createElement("div");var style=elem.style;style.cssText="background-color:rgba(0,0,0,.5)";return contains(style.backgroundColor,"rgba")||contains(style.backgroundColor,"hsla")}(),replaceInput=["
    ","
    ","
    ","
    "].join(""),markup=function(){var gradientFix="";if(IE){for(var i=1;i<=6;i++){gradientFix+="
    "}}return["
    ","
    ","
    ","
    ","","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ",gradientFix,"
    ","
    ","
    ","
    ","
    ","","
    ","
    ","
    ","","","
    ","
    ","
    "].join("")}();function paletteTemplate(p,color,className,opts){var html=[];for(var i=0;i')}else{var cls="sp-clear-display";html.push($("
    ").append($('').attr("title",opts.noColorSelectedText)).html())}}return"
    "+html.join("")+"
    "}function hideAll(){for(var i=0;iMath.abs(dragY-oldDragY);shiftMovementDirection=furtherFromX?"x":"y"}var setSaturation=!shiftMovementDirection||shiftMovementDirection==="x";var setValue=!shiftMovementDirection||shiftMovementDirection==="y";if(setSaturation){currentSaturation=parseFloat(dragX/dragWidth)}if(setValue){currentValue=parseFloat((dragHeight-dragY)/dragHeight)}isEmpty=false;if(!opts.showAlpha){currentAlpha=1}move()},dragStart,dragStop);if(!!initialColor){set(initialColor);updateUI();currentPreferredFormat=opts.preferredFormat||tinycolor(initialColor).format;addColorToSelectionPalette(initialColor)}else{updateUI()}if(flat){show()}function paletteElementClick(e){if(e.data&&e.data.ignore){set($(e.target).closest(".sp-thumb-el").data("color"));move()}else{set($(e.target).closest(".sp-thumb-el").data("color"));move();updateOriginalInput(true);if(opts.hideAfterPaletteSelect){hide()}}return false}var paletteEvent=IE?"mousedown.spectrum":"click.spectrum touchstart.spectrum";paletteContainer.delegate(".sp-thumb-el",paletteEvent,paletteElementClick);initialColorContainer.delegate(".sp-thumb-el:nth-child(1)",paletteEvent,{ignore:true},paletteElementClick)}function updateSelectionPaletteFromStorage(){if(localStorageKey&&window.localStorage){try{var oldPalette=window.localStorage[localStorageKey].split(",#");if(oldPalette.length>1){delete window.localStorage[localStorageKey];$.each(oldPalette,function(i,c){addColorToSelectionPalette(c)})}}catch(e){}try{selectionPalette=window.localStorage[localStorageKey].split(";")}catch(e){}}}function addColorToSelectionPalette(color){if(showSelectionPalette){var rgb=tinycolor(color).toRgbString();if(!paletteLookup[rgb]&&$.inArray(rgb,selectionPalette)===-1){selectionPalette.push(rgb);while(selectionPalette.length>maxSelectionSize){selectionPalette.shift()}}if(localStorageKey&&window.localStorage){try{window.localStorage[localStorageKey]=selectionPalette.join(";")}catch(e){}}}}function getUniqueSelectionPalette(){var unique=[];if(opts.showPalette){for(var i=0;iviewWidth&&viewWidth>dpWidth?Math.abs(offset.left+dpWidth-viewWidth):0);offset.top-=Math.min(offset.top,offset.top+dpHeight>viewHeight&&viewHeight>dpHeight?Math.abs(dpHeight+inputHeight-extraY):extraY);return offset}function noop(){}function stopPropagation(e){e.stopPropagation()}function bind(func,obj){var slice=Array.prototype.slice;var args=slice.call(arguments,2);return function(){return func.apply(obj,args.concat(slice.call(arguments)))}}function draggable(element,onmove,onstart,onstop){onmove=onmove||function(){};onstart=onstart||function(){};onstop=onstop||function(){};var doc=document;var dragging=false;var offset={};var maxHeight=0;var maxWidth=0;var hasTouch="ontouchstart"in window;var duringDragEvents={};duringDragEvents["selectstart"]=prevent;duringDragEvents["dragstart"]=prevent;duringDragEvents["touchmove mousemove"]=move;duringDragEvents["touchend mouseup"]=stop;function prevent(e){if(e.stopPropagation){e.stopPropagation()}if(e.preventDefault){e.preventDefault()}e.returnValue=false}function move(e){if(dragging){if(IE&&doc.documentMode<9&&!e.button){return stop()}var t0=e.originalEvent&&e.originalEvent.touches&&e.originalEvent.touches[0];var pageX=t0&&t0.pageX||e.pageX;var pageY=t0&&t0.pageY||e.pageY;var dragX=Math.max(0,Math.min(pageX-offset.left,maxWidth));var dragY=Math.max(0,Math.min(pageY-offset.top,maxHeight));if(hasTouch){prevent(e)}onmove.apply(element,[dragX,dragY,e])}}function start(e){var rightclick=e.which?e.which==3:e.button==2;if(!rightclick&&!dragging){if(onstart.apply(element,arguments)!==false){dragging=true;maxHeight=$(element).height();maxWidth=$(element).width();offset=$(element).offset();$(doc).bind(duringDragEvents);$(doc.body).addClass("sp-dragging");move(e);prevent(e)}}}function stop(){if(dragging){$(doc).unbind(duringDragEvents);$(doc.body).removeClass("sp-dragging");setTimeout(function(){onstop.apply(element,arguments)},0)}dragging=false}$(element).bind("touchstart mousedown",start)}function throttle(func,wait,debounce){var timeout;return function(){var context=this,args=arguments;var throttler=function(){timeout=null;func.apply(context,args)};if(debounce)clearTimeout(timeout);if(debounce||!timeout)timeout=setTimeout(throttler,wait)}}function inputTypeColorSupport(){return $.fn.spectrum.inputTypeColorSupport()}var dataID="spectrum.id";$.fn.spectrum=function(opts,extra){if(typeof opts=="string"){var returnValue=this;var args=Array.prototype.slice.call(arguments,1);this.each(function(){var spect=spectrums[$(this).data(dataID)];if(spect){var method=spect[opts];if(!method){throw new Error("Spectrum: no such method: '"+opts+"'")}if(opts=="get"){returnValue=spect.get()}else if(opts=="container"){returnValue=spect.container}else if(opts=="option"){returnValue=spect.option.apply(spect,args)}else if(opts=="destroy"){spect.destroy();$(this).removeData(dataID)}else{method.apply(spect,args)}}});return returnValue}return this.spectrum("destroy").each(function(){var options=$.extend({},opts,$(this).data());var spect=spectrum(this,options);$(this).data(dataID,spect.id)})};$.fn.spectrum.load=true;$.fn.spectrum.loadOpts={};$.fn.spectrum.draggable=draggable;$.fn.spectrum.defaults=defaultOpts;$.fn.spectrum.inputTypeColorSupport=function inputTypeColorSupport(){if(typeof inputTypeColorSupport._cachedResult==="undefined"){var colorInput=$("")[0];inputTypeColorSupport._cachedResult=colorInput.type==="color"&&colorInput.value!==""}return inputTypeColorSupport._cachedResult};$.spectrum={};$.spectrum.localization={};$.spectrum.palettes={};$.fn.spectrum.processNativeColorInputs=function(){var colorInputs=$("input[type=color]");if(colorInputs.length&&!inputTypeColorSupport()){colorInputs.spectrum({preferredFormat:"hex6"})}};(function(){var trimLeft=/^[\s,#]+/,trimRight=/\s+$/,tinyCounter=0,math=Math,mathRound=math.round,mathMin=math.min,mathMax=math.max,mathRandom=math.random;var tinycolor=function(color,opts){color=color?color:"";opts=opts||{};if(color instanceof tinycolor){return color}if(!(this instanceof tinycolor)){return new tinycolor(color,opts)}var rgb=inputToRGB(color);this._originalInput=color,this._r=rgb.r,this._g=rgb.g,this._b=rgb.b,this._a=rgb.a,this._roundA=mathRound(100*this._a)/100,this._format=opts.format||rgb.format;this._gradientType=opts.gradientType;if(this._r<1){this._r=mathRound(this._r)}if(this._g<1){this._g=mathRound(this._g)}if(this._b<1){this._b=mathRound(this._b)}this._ok=rgb.ok;this._tc_id=tinyCounter++};tinycolor.prototype={isDark:function(){return this.getBrightness()<128},isLight:function(){return!this.isDark()},isValid:function(){return this._ok},getOriginalInput:function(){return this._originalInput},getFormat:function(){return this._format},getAlpha:function(){return this._a},getBrightness:function(){var rgb=this.toRgb();return(rgb.r*299+rgb.g*587+rgb.b*114)/1e3},setAlpha:function(value){this._a=boundAlpha(value);this._roundA=mathRound(100*this._a)/100;return this},toHsv:function(){var hsv=rgbToHsv(this._r,this._g,this._b);return{h:hsv.h*360,s:hsv.s,v:hsv.v,a:this._a}},toHsvString:function(){var hsv=rgbToHsv(this._r,this._g,this._b);var h=mathRound(hsv.h*360),s=mathRound(hsv.s*100),v=mathRound(hsv.v*100);return this._a==1?"hsv("+h+", "+s+"%, "+v+"%)":"hsva("+h+", "+s+"%, "+v+"%, "+this._roundA+")"},toHsl:function(){var hsl=rgbToHsl(this._r,this._g,this._b);return{h:hsl.h*360,s:hsl.s,l:hsl.l,a:this._a}},toHslString:function(){var hsl=rgbToHsl(this._r,this._g,this._b);var h=mathRound(hsl.h*360),s=mathRound(hsl.s*100),l=mathRound(hsl.l*100);return this._a==1?"hsl("+h+", "+s+"%, "+l+"%)":"hsla("+h+", "+s+"%, "+l+"%, "+this._roundA+")"},toHex:function(allow3Char){return rgbToHex(this._r,this._g,this._b,allow3Char)},toHexString:function(allow3Char){return"#"+this.toHex(allow3Char)},toHex8:function(){return rgbaToHex(this._r,this._g,this._b,this._a)},toHex8String:function(){return"#"+this.toHex8()},toRgb:function(){return{r:mathRound(this._r),g:mathRound(this._g),b:mathRound(this._b),a:this._a}},toRgbString:function(){return this._a==1?"rgb("+mathRound(this._r)+", "+mathRound(this._g)+", "+mathRound(this._b)+")":"rgba("+mathRound(this._r)+", "+mathRound(this._g)+", "+mathRound(this._b)+", "+this._roundA+")"},toPercentageRgb:function(){return{r:mathRound(bound01(this._r,255)*100)+"%",g:mathRound(bound01(this._g,255)*100)+"%",b:mathRound(bound01(this._b,255)*100)+"%",a:this._a}},toPercentageRgbString:function(){return this._a==1?"rgb("+mathRound(bound01(this._r,255)*100)+"%, "+mathRound(bound01(this._g,255)*100)+"%, "+mathRound(bound01(this._b,255)*100)+"%)":"rgba("+mathRound(bound01(this._r,255)*100)+"%, "+mathRound(bound01(this._g,255)*100)+"%, "+mathRound(bound01(this._b,255)*100)+"%, "+this._roundA+")"},toName:function(){if(this._a===0){return"transparent"}if(this._a<1){return false}return hexNames[rgbToHex(this._r,this._g,this._b,true)]||false},toFilter:function(secondColor){var hex8String="#"+rgbaToHex(this._r,this._g,this._b,this._a);var secondHex8String=hex8String;var gradientType=this._gradientType?"GradientType = 1, ":"";if(secondColor){var s=tinycolor(secondColor);secondHex8String=s.toHex8String()}return"progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")"},toString:function(format){var formatSet=!!format;format=format||this._format;var formattedString=false;var hasAlpha=this._a<1&&this._a>=0;var needsAlphaFormat=!formatSet&&hasAlpha&&(format==="hex"||format==="hex6"||format==="hex3"||format==="name");if(needsAlphaFormat){if(format==="name"&&this._a===0){return this.toName()}return this.toRgbString()}if(format==="rgb"){formattedString=this.toRgbString()}if(format==="prgb"){formattedString=this.toPercentageRgbString()}if(format==="hex"||format==="hex6"){formattedString=this.toHexString()}if(format==="hex3"){formattedString=this.toHexString(true)}if(format==="hex8"){formattedString=this.toHex8String()}if(format==="name"){formattedString=this.toName()}if(format==="hsl"){formattedString=this.toHslString()}if(format==="hsv"){formattedString=this.toHsvString()}return formattedString||this.toHexString()},_applyModification:function(fn,args){var color=fn.apply(null,[this].concat([].slice.call(args)));this._r=color._r;this._g=color._g;this._b=color._b;this.setAlpha(color._a);return this},lighten:function(){return this._applyModification(lighten,arguments)},brighten:function(){return this._applyModification(brighten,arguments)},darken:function(){return this._applyModification(darken,arguments)},desaturate:function(){return this._applyModification(desaturate,arguments)},saturate:function(){return this._applyModification(saturate,arguments)},greyscale:function(){return this._applyModification(greyscale,arguments)},spin:function(){return this._applyModification(spin,arguments)},_applyCombination:function(fn,args){return fn.apply(null,[this].concat([].slice.call(args)))},analogous:function(){return this._applyCombination(analogous,arguments)},complement:function(){return this._applyCombination(complement,arguments)},monochromatic:function(){return this._applyCombination(monochromatic,arguments)},splitcomplement:function(){return this._applyCombination(splitcomplement,arguments)},triad:function(){return this._applyCombination(triad,arguments)},tetrad:function(){return this._applyCombination(tetrad,arguments)}};tinycolor.fromRatio=function(color,opts){if(typeof color=="object"){var newColor={};for(var i in color){if(color.hasOwnProperty(i)){if(i==="a"){newColor[i]=color[i]}else{newColor[i]=convertToPercentage(color[i])}}}color=newColor}return tinycolor(color,opts)};function inputToRGB(color){var rgb={r:0,g:0,b:0};var a=1;var ok=false;var format=false;if(typeof color=="string"){color=stringInputToObject(color)}if(typeof color=="object"){if(color.hasOwnProperty("r")&&color.hasOwnProperty("g")&&color.hasOwnProperty("b")){rgb=rgbToRgb(color.r,color.g,color.b);ok=true;format=String(color.r).substr(-1)==="%"?"prgb":"rgb"}else if(color.hasOwnProperty("h")&&color.hasOwnProperty("s")&&color.hasOwnProperty("v")){color.s=convertToPercentage(color.s);color.v=convertToPercentage(color.v);rgb=hsvToRgb(color.h,color.s,color.v);ok=true;format="hsv"}else if(color.hasOwnProperty("h")&&color.hasOwnProperty("s")&&color.hasOwnProperty("l")){color.s=convertToPercentage(color.s);color.l=convertToPercentage(color.l);rgb=hslToRgb(color.h,color.s,color.l);ok=true;format="hsl"}if(color.hasOwnProperty("a")){a=color.a}}a=boundAlpha(a);return{ok:ok,format:color.format||format,r:mathMin(255,mathMax(rgb.r,0)),g:mathMin(255,mathMax(rgb.g,0)),b:mathMin(255,mathMax(rgb.b,0)),a:a}}function rgbToRgb(r,g,b){return{r:bound01(r,255)*255,g:bound01(g,255)*255,b:bound01(b,255)*255}}function rgbToHsl(r,g,b){r=bound01(r,255);g=bound01(g,255);b=bound01(b,255);var max=mathMax(r,g,b),min=mathMin(r,g,b);var h,s,l=(max+min)/2;if(max==min){h=s=0}else{var d=max-min;s=l>.5?d/(2-max-min):d/(max+min);switch(max){case r:h=(g-b)/d+(g1)t-=1;if(t<1/6)return p+(q-p)*6*t;if(t<1/2)return q;if(t<2/3)return p+(q-p)*(2/3-t)*6;return p}if(s===0){r=g=b=l}else{var q=l<.5?l*(1+s):l+s-l*s;var p=2*l-q;r=hue2rgb(p,q,h+1/3);g=hue2rgb(p,q,h);b=hue2rgb(p,q,h-1/3)}return{r:r*255,g:g*255,b:b*255}}function rgbToHsv(r,g,b){r=bound01(r,255);g=bound01(g,255);b=bound01(b,255);var max=mathMax(r,g,b),min=mathMin(r,g,b);var h,s,v=max;var d=max-min;s=max===0?0:d/max;if(max==min){h=0}else{switch(max){case r:h=(g-b)/d+(g>1)+720)%360;--results;){hsl.h=(hsl.h+part)%360;ret.push(tinycolor(hsl))}return ret}function monochromatic(color,results){results=results||6;var hsv=tinycolor(color).toHsv();var h=hsv.h,s=hsv.s,v=hsv.v;var ret=[];var modification=1/results;while(results--){ret.push(tinycolor({h:h,s:s,v:v}));v=(v+modification)%1}return ret}tinycolor.mix=function(color1,color2,amount){amount=amount===0?0:amount||50;var rgb1=tinycolor(color1).toRgb();var rgb2=tinycolor(color2).toRgb();var p=amount/100;var w=p*2-1;var a=rgb2.a-rgb1.a;var w1;if(w*a==-1){w1=w}else{w1=(w+a)/(1+w*a)}w1=(w1+1)/2;var w2=1-w1;var rgba={r:rgb2.r*w1+rgb1.r*w2,g:rgb2.g*w1+rgb1.g*w2,b:rgb2.b*w1+rgb1.b*w2,a:rgb2.a*p+rgb1.a*(1-p)};return tinycolor(rgba)};tinycolor.readability=function(color1,color2){var c1=tinycolor(color1);var c2=tinycolor(color2);var rgb1=c1.toRgb();var rgb2=c2.toRgb();var brightnessA=c1.getBrightness();var brightnessB=c2.getBrightness();var colorDiff=Math.max(rgb1.r,rgb2.r)-Math.min(rgb1.r,rgb2.r)+Math.max(rgb1.g,rgb2.g)-Math.min(rgb1.g,rgb2.g)+Math.max(rgb1.b,rgb2.b)-Math.min(rgb1.b,rgb2.b);return{brightness:Math.abs(brightnessA-brightnessB),color:colorDiff}};tinycolor.isReadable=function(color1,color2){var readability=tinycolor.readability(color1,color2);return readability.brightness>125&&readability.color>500};tinycolor.mostReadable=function(baseColor,colorList){var bestColor=null;var bestScore=0;var bestIsReadable=false;for(var i=0;i125&&readability.color>500;var score=3*(readability.brightness/125)+readability.color/500;if(readable&&!bestIsReadable||readable&&bestIsReadable&&score>bestScore||!readable&&!bestIsReadable&&score>bestScore){bestIsReadable=readable;bestScore=score;bestColor=tinycolor(colorList[i])}}return bestColor};var names=tinycolor.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"};var hexNames=tinycolor.hexNames=flip(names);function flip(o){var flipped={};for(var i in o){if(o.hasOwnProperty(i)){flipped[o[i]]=i}}return flipped}function boundAlpha(a){a=parseFloat(a);if(isNaN(a)||a<0||a>1){a=1}return a}function bound01(n,max){if(isOnePointZero(n)){n="100%"}var processPercent=isPercentage(n);n=mathMin(max,mathMax(0,parseFloat(n)));if(processPercent){n=parseInt(n*max,10)/100}if(math.abs(n-max)<1e-6){return 1}return n%max/parseFloat(max)}function clamp01(val){return mathMin(1,mathMax(0,val))}function parseIntFromHex(val){return parseInt(val,16)}function isOnePointZero(n){return typeof n=="string"&&n.indexOf(".")!=-1&&parseFloat(n)===1}function isPercentage(n){return typeof n==="string"&&n.indexOf("%")!=-1}function pad2(c){return c.length==1?"0"+c:""+c}function convertToPercentage(n){if(n<=1){n=n*100+"%"}return n}function convertDecimalToHex(d){return Math.round(parseFloat(d)*255).toString(16)}function convertHexToDecimal(h){return parseIntFromHex(h)/255}var matchers=function(){var CSS_INTEGER="[-\\+]?\\d+%?";var CSS_NUMBER="[-\\+]?\\d*\\.\\d+%?";var CSS_UNIT="(?:"+CSS_NUMBER+")|(?:"+CSS_INTEGER+")";var PERMISSIVE_MATCH3="[\\s|\\(]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")\\s*\\)?";var PERMISSIVE_MATCH4="[\\s|\\(]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")\\s*\\)?";return{rgb:new RegExp("rgb"+PERMISSIVE_MATCH3),rgba:new RegExp("rgba"+PERMISSIVE_MATCH4),hsl:new RegExp("hsl"+PERMISSIVE_MATCH3),hsla:new RegExp("hsla"+PERMISSIVE_MATCH4),hsv:new RegExp("hsv"+PERMISSIVE_MATCH3),hsva:new RegExp("hsva"+PERMISSIVE_MATCH4),hex3:/^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex8:/^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/}}();function stringInputToObject(color){color=color.replace(trimLeft,"").replace(trimRight,"").toLowerCase();var named=false;if(names[color]){color=names[color];named=true}else if(color=="transparent"){return{r:0,g:0,b:0,a:0,format:"name"}}var match;if(match=matchers.rgb.exec(color)){return{r:match[1],g:match[2],b:match[3]}}if(match=matchers.rgba.exec(color)){return{r:match[1],g:match[2],b:match[3],a:match[4]}}if(match=matchers.hsl.exec(color)){return{h:match[1],s:match[2],l:match[3]}}if(match=matchers.hsla.exec(color)){return{h:match[1],s:match[2],l:match[3],a:match[4]}}if(match=matchers.hsv.exec(color)){return{h:match[1],s:match[2],v:match[3]}}if(match=matchers.hsva.exec(color)){return{h:match[1],s:match[2],v:match[3],a:match[4]}}if(match=matchers.hex8.exec(color)){return{a:convertHexToDecimal(match[1]),r:parseIntFromHex(match[2]),g:parseIntFromHex(match[3]),b:parseIntFromHex(match[4]),format:named?"name":"hex8"}}if(match=matchers.hex6.exec(color)){return{r:parseIntFromHex(match[1]),g:parseIntFromHex(match[2]),b:parseIntFromHex(match[3]),format:named?"name":"hex"}}if(match=matchers.hex3.exec(color)){return{r:parseIntFromHex(match[1]+""+match[1]),g:parseIntFromHex(match[2]+""+match[2]),b:parseIntFromHex(match[3]+""+match[3]),format:named?"name":"hex"}}return false}window.tinycolor=tinycolor})();$(function(){if($.fn.spectrum.load){$.fn.spectrum.processNativeColorInputs()}})}); \ No newline at end of file diff --git a/spec/components/discourse_sass_compiler_spec.rb b/spec/components/discourse_sass_compiler_spec.rb deleted file mode 100644 index baa998cf3a4..00000000000 --- a/spec/components/discourse_sass_compiler_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'rails_helper' -require_dependency 'sass/discourse_sass_compiler' - -describe DiscourseSassCompiler do - - let(:test_scss) { "body { p {color: blue;} }\n@import 'common/foundation/variables';\n@import 'plugins';" } - - describe '#compile' do - it "compiles scss" do - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - css = described_class.compile(test_scss, "test") - expect(css).to include("color") - expect(css).to include('my-plugin-thing') - end - - it "raises error for invalid scss" do - expect { - described_class.compile("this isn't valid scss", "test") - }.to raise_error(Sass::SyntaxError) - end - - it "doesn't load theme or plugins in safe mode" do - ColorScheme.expects(:enabled).never - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - css = described_class.compile(test_scss, "test", safe: true) - expect(css).not_to include('my-plugin-thing') - end - end - -end diff --git a/spec/components/discourse_stylesheets_spec.rb b/spec/components/discourse_stylesheets_spec.rb deleted file mode 100644 index 30562a58449..00000000000 --- a/spec/components/discourse_stylesheets_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'rails_helper' -require_dependency 'sass/discourse_stylesheets' - -describe DiscourseStylesheets do - - describe "compile" do - it "can compile desktop bundle" do - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - builder = described_class.new(:desktop) - expect(builder.compile(force: true)).to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - - it "can compile mobile bundle" do - DiscoursePluginRegistry.stubs(:mobile_stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - builder = described_class.new(:mobile) - expect(builder.compile(force: true)).to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - - it "can fallback when css is bad" do - DiscoursePluginRegistry.stubs(:stylesheets).returns([ - "#{Rails.root}/spec/fixtures/scss/my_plugin.scss", - "#{Rails.root}/spec/fixtures/scss/broken.scss" - ]) - builder = described_class.new(:desktop) - expect(builder.compile(force: true)).not_to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - end - - describe "#digest" do - before do - described_class.expects(:max_file_mtime).returns(Time.new(2016, 06, 05, 12, 30, 0, 0)) - end - - it "should return a digest" do - expect(described_class.new.digest).to eq('0e6c2e957cfc92ed60661c90ec3345198ccef887') - end - - it "should include the cdn url when generating the digest" do - GlobalSetting.expects(:cdn_url).returns('https://fastly.maxcdn.org') - expect(described_class.new.digest).to eq('4995163b1232c54c8ed3b44200d803a90bc47613') - end - end -end diff --git a/spec/components/step_updater_spec.rb b/spec/components/step_updater_spec.rb index eed8e7f7f5b..783d2a1d580 100644 --- a/spec/components/step_updater_spec.rb +++ b/spec/components/step_updater_spec.rb @@ -151,27 +151,31 @@ describe Wizard::StepUpdater do let!(:color_scheme) { Fabricate(:color_scheme, name: 'existing', via_wizard: true) } it "updates the scheme" do - updater = wizard.create_updater('colors', theme_id: 'dark') + updater = wizard.create_updater('colors', base_scheme_id: 'dark') updater.update expect(updater.success?).to eq(true) expect(wizard.completed_steps?('colors')).to eq(true) - color_scheme.reload - expect(color_scheme).to be_enabled + + theme = Theme.find_by(key: SiteSetting.default_theme_key) + expect(theme.color_scheme_id).to eq(color_scheme.id) + end end context "without an existing scheme" do it "creates the scheme" do - updater = wizard.create_updater('colors', theme_id: 'dark') + updater = wizard.create_updater('colors', base_scheme_id: 'dark') updater.update expect(updater.success?).to eq(true) expect(wizard.completed_steps?('colors')).to eq(true) color_scheme = ColorScheme.where(via_wizard: true).first expect(color_scheme).to be_present - expect(color_scheme).to be_enabled expect(color_scheme.colors).to be_present + + theme = Theme.find_by(key: SiteSetting.default_theme_key) + expect(theme.color_scheme_id).to eq(color_scheme.id) end end end diff --git a/spec/components/stylesheet/compiler_spec.rb b/spec/components/stylesheet/compiler_spec.rb new file mode 100644 index 00000000000..5bd19ce32dd --- /dev/null +++ b/spec/components/stylesheet/compiler_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' +require 'stylesheet/compiler' + +describe Stylesheet::Compiler do + it "can compile desktop mobile and desktop css" do + css,_map = Stylesheet::Compiler.compile_asset("desktop") + expect(css.length).to be > 1000 + + css,_map = Stylesheet::Compiler.compile_asset("mobile") + expect(css.length).to be > 1000 + end + + it "supports asset-url" do + css,_map = Stylesheet::Compiler.compile(".body{background-image: asset-url('foo.png');}","test.scss") + + expect(css).to include("url('/foo.png')") + expect(css).not_to include('asset-url') + end +end + + diff --git a/spec/components/stylesheet/manager_spec.rb b/spec/components/stylesheet/manager_spec.rb new file mode 100644 index 00000000000..3098e6000c7 --- /dev/null +++ b/spec/components/stylesheet/manager_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' +require 'stylesheet/compiler' + +describe Stylesheet::Manager do + it 'can correctly compile theme css' do + theme = Theme.new( + name: 'parent', + user_id: -1 + ) + + theme.set_field(:common, "scss", ".common{.scss{color: red;}}") + theme.set_field(:desktop, "scss", ".desktop{.scss{color: red;}}") + theme.set_field(:mobile, "scss", ".mobile{.scss{color: red;}}") + theme.set_field(:common, "embedded_scss", ".embedded{.scss{color: red;}}") + + theme.save! + + + child_theme = Theme.new( + name: 'parent', + user_id: -1, + ) + + child_theme.set_field(:common, "scss", ".child_common{.scss{color: red;}}") + child_theme.set_field(:desktop, "scss", ".child_desktop{.scss{color: red;}}") + child_theme.set_field(:mobile, "scss", ".child_mobile{.scss{color: red;}}") + child_theme.set_field(:common, "embedded_scss", ".child_embedded{.scss{color: red;}}") + child_theme.save! + + theme.add_child_theme!(child_theme) + + old_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key) + + manager = Stylesheet::Manager.new(:desktop_theme, theme.key) + manager.compile(force: true) + + css = File.read(manager.stylesheet_fullpath) + _source_map = File.read(manager.source_map_fullpath) + + expect(css).to match(/child_common/) + expect(css).to match(/child_desktop/) + expect(css).to match(/\.common/) + expect(css).to match(/\.desktop/) + + + child_theme.set_field(:desktop, :scss, ".nothing{color: green;}") + child_theme.save! + + new_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key) + + expect(new_link).not_to eq(old_link) + + # our theme better have a name with the theme_id as part of it + expect(new_link).to include("/stylesheets/desktop_theme_#{theme.id}_") + end +end + diff --git a/spec/controllers/admin/color_schemes_controller_spec.rb b/spec/controllers/admin/color_schemes_controller_spec.rb index 9059330b44d..c1eb2c9abb9 100644 --- a/spec/controllers/admin/color_schemes_controller_spec.rb +++ b/spec/controllers/admin/color_schemes_controller_spec.rb @@ -9,7 +9,6 @@ describe Admin::ColorSchemesController do let!(:user) { log_in(:admin) } let(:valid_params) { { color_scheme: { name: 'Such Design', - enabled: true, colors: [ {name: 'primary', hex: 'FFBB00'}, {name: 'secondary', hex: '888888'} diff --git a/spec/controllers/admin/site_customizations_controller_spec.rb b/spec/controllers/admin/site_customizations_controller_spec.rb deleted file mode 100644 index 2695f17c7e7..00000000000 --- a/spec/controllers/admin/site_customizations_controller_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rails_helper' - -describe Admin::SiteCustomizationsController do - - it "is a subclass of AdminController" do - expect(Admin::UsersController < Admin::AdminController).to eq(true) - end - - context 'while logged in as an admin' do - before do - @user = log_in(:admin) - end - - context ' .index' do - it 'returns success' do - SiteCustomization.create!(name: 'my name', user_id: Fabricate(:user).id, header: "my awesome header", stylesheet: "my awesome css") - xhr :get, :index - expect(response).to be_success - end - - it 'returns JSON' do - xhr :get, :index - expect(::JSON.parse(response.body)).to be_present - end - end - - context ' .create' do - it 'returns success' do - xhr :post, :create, site_customization: {name: 'my test name'} - expect(response).to be_success - end - - it 'returns json' do - xhr :post, :create, site_customization: {name: 'my test name'} - expect(::JSON.parse(response.body)).to be_present - end - - it 'logs the change' do - StaffActionLogger.any_instance.expects(:log_site_customization_change).once - xhr :post, :create, site_customization: {name: 'my test name'} - end - end - - end - - - -end diff --git a/spec/controllers/admin/staff_action_logs_controller_spec.rb b/spec/controllers/admin/staff_action_logs_controller_spec.rb index a5665717089..6f8b64feca8 100644 --- a/spec/controllers/admin/staff_action_logs_controller_spec.rb +++ b/spec/controllers/admin/staff_action_logs_controller_spec.rb @@ -8,15 +8,35 @@ describe Admin::StaffActionLogsController do let!(:user) { log_in(:admin) } context '.index' do - before do + + it 'works' do xhr :get, :index + expect(response).to be_success + expect(::JSON.parse(response.body)).to be_a(Array) end + end - subject { response } - it { is_expected.to be_success } + context '.diff' do + it 'can generate diffs for theme changes' do + theme = Theme.new(user_id: -1, name: 'bob') + theme.set_field(:mobile, :scss, 'body {.up}') + theme.set_field(:common, :scss, 'omit-dupe') - it 'returns JSON' do - expect(::JSON.parse(subject.body)).to be_a(Array) + original_json = ThemeSerializer.new(theme, root: false).to_json + + theme.set_field(:mobile, :scss, 'body {.down}') + + record = StaffActionLogger.new(Discourse.system_user) + .log_theme_change(original_json, theme) + + xhr :get, :diff, id: record.id + expect(response).to be_success + + parsed = JSON.parse(response.body) + expect(parsed["side_by_side"]).to include("up") + expect(parsed["side_by_side"]).to include("down") + + expect(parsed["side_by_side"]).not_to include("omit-dupe") end end end diff --git a/spec/controllers/admin/themes_controller_spec.rb b/spec/controllers/admin/themes_controller_spec.rb new file mode 100644 index 00000000000..7595b546520 --- /dev/null +++ b/spec/controllers/admin/themes_controller_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +describe Admin::ThemesController do + + it "is a subclass of AdminController" do + expect(Admin::UsersController < Admin::AdminController).to eq(true) + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context ' .index' do + it 'returns success' do + theme = Theme.new(name: 'my name', user_id: -1) + theme.set_field(:common, :scss, '.body{color: black;}') + theme.set_field(:desktop, :after_header, 'test') + + theme.remote_theme = RemoteTheme.new( + remote_url: 'awesome.git', + remote_version: '7', + local_version: '8', + remote_updated_at: Time.zone.now + ) + + theme.save! + + # this will get serialized as well + ColorScheme.create_from_base(name: "test", colors: []) + + xhr :get, :index + + expect(response).to be_success + + json = ::JSON.parse(response.body) + + expect(json["extras"]["color_schemes"].length).to eq(2) + theme_json = json["themes"].find{|t| t["id"] == theme.id} + expect(theme_json["theme_fields"].length).to eq(2) + expect(theme_json["remote_theme"]["remote_version"]).to eq("7") + end + end + + context ' .create' do + it 'creates a theme' do + xhr :post, :create, theme: {name: 'my test name', theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}']} + expect(response).to be_success + + json = ::JSON.parse(response.body) + + expect(json["theme"]["theme_fields"].length).to eq(1) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + + context ' .update' do + it 'can change default theme' do + theme = Theme.create(name: 'my name', user_id: -1) + xhr :put, :update, id: theme.id, theme: { default: true } + expect(SiteSetting.default_theme_key).to eq(theme.key) + end + + it 'can unset default theme' do + theme = Theme.create(name: 'my name', user_id: -1) + SiteSetting.default_theme_key = theme.key + xhr :put, :update, id: theme.id, theme: { default: false} + expect(SiteSetting.default_theme_key).to be_blank + end + + it 'updates a theme' do + + theme = Theme.new(name: 'my name', user_id: -1) + theme.set_field(:common, :scss, '.body{color: black;}') + theme.save + + child_theme = Theme.create(name: 'my name', user_id: -1) + + xhr :put, :update, id: theme.id, + theme: { + child_theme_ids: [child_theme.id], + name: 'my test name', + theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}'] + } + expect(response).to be_success + + json = ::JSON.parse(response.body) + + fields = json["theme"]["theme_fields"] + + expect(fields.length).to eq(1) + expect(fields.first["value"]).to eq('body{color: red;}') + + expect(json["theme"]["child_themes"].length).to eq(1) + + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + end + +end diff --git a/spec/controllers/site_customizations_controller_spec.rb b/spec/controllers/site_customizations_controller_spec.rb deleted file mode 100644 index d3a0f17451d..00000000000 --- a/spec/controllers/site_customizations_controller_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -require 'rails_helper' - -describe SiteCustomizationsController do - - before do - SiteCustomization.clear_cache! - end - - it 'can deliver enabled css' do - SiteCustomization.create!(name: '1', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a1{margin: 1px;}', - stylesheet: '.b1{margin: 1px;}' - ) - - SiteCustomization.create!(name: '2', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a2{margin: 1px;}', - stylesheet: '.b2{margin: 1px;}' - ) - - get :show, key: SiteCustomization::ENABLED_KEY, format: :css, target: 'mobile' - expect(response.body).to match(/\.a1.*\.a2/m) - - get :show, key: SiteCustomization::ENABLED_KEY, format: :css - expect(response.body).to match(/\.b1.*\.b2/m) - end - - it 'can deliver specific css' do - c = SiteCustomization.create!(name: '1', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a1{margin: 1px;}', - stylesheet: '.b1{margin: 1px;}' - ) - - get :show, key: c.key, format: :css, target: 'mobile' - expect(response.body).to match(/\.a1/) - - get :show, key: c.key, format: :css - expect(response.body).to match(/\.b1/) - end -end diff --git a/spec/controllers/stylesheets_controller_spec.rb b/spec/controllers/stylesheets_controller_spec.rb index 5a5f9cf05f7..7351091f79c 100644 --- a/spec/controllers/stylesheets_controller_spec.rb +++ b/spec/controllers/stylesheets_controller_spec.rb @@ -5,7 +5,7 @@ describe StylesheetsController do it 'can survive cache miss' do StylesheetCache.destroy_all - builder = DiscourseStylesheets.new('desktop_rtl') + builder = Stylesheet::Manager.new('desktop_rtl', nil) builder.compile builder.ensure_digestless_file @@ -26,7 +26,7 @@ describe StylesheetsController do expect(cached.digest).to eq digest # tmp folder destruction and cached - `rm #{DiscourseStylesheets.cache_fullpath}/*` + `rm #{Stylesheet::Manager.cache_fullpath}/*` get :show, name: 'desktop_rtl' expect(response).to be_success @@ -38,4 +38,31 @@ describe StylesheetsController do end + it 'can lookup theme specific css' do + scheme = ColorScheme.create_from_base({name: "testing", colors: []}) + theme = Theme.create!(name: "test", color_scheme_id: scheme.id, user_id: -1) + + builder = Stylesheet::Manager.new(:desktop, theme.key) + builder.compile + + `rm #{Stylesheet::Manager.cache_fullpath}/*` + + get :show, name: builder.stylesheet_filename.sub(".css", "") + expect(response).to be_success + + get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "") + expect(response).to be_success + + builder = Stylesheet::Manager.new(:desktop_theme, theme.key) + builder.compile + + `rm #{Stylesheet::Manager.cache_fullpath}/*` + + get :show, name: builder.stylesheet_filename.sub(".css", "") + expect(response).to be_success + + get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "") + expect(response).to be_success + end + end diff --git a/spec/fabricators/color_scheme_fabricator.rb b/spec/fabricators/color_scheme_fabricator.rb index 09bde58ef2f..111964527a0 100644 --- a/spec/fabricators/color_scheme_fabricator.rb +++ b/spec/fabricators/color_scheme_fabricator.rb @@ -1,5 +1,4 @@ Fabricator(:color_scheme) do name { sequence(:name) {|i| "Palette #{i}" } } - enabled false color_scheme_colors(count: 2) { |attrs, i| Fabricate.build(:color_scheme_color, color_scheme: nil) } end diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb index 34f75e4d940..4d07dac97d9 100644 --- a/spec/models/color_scheme_spec.rb +++ b/spec/models/color_scheme_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' describe ColorScheme do - let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} } + let(:valid_params) { {name: "Best Colors Evar", colors: valid_colors} } let(:valid_colors) { [ {name: '$primary_background_color', hex: 'FFBB00'}, {name: '$secondary_background_color', hex: '888888'} @@ -10,7 +10,7 @@ describe ColorScheme do describe "new" do it "can take colors" do - c = described_class.new(valid_params) + c = ColorScheme.new(valid_params) expect(c.colors.size).to eq valid_colors.size expect(c.colors.first).to be_a(ColorSchemeColor) expect { @@ -55,29 +55,4 @@ describe ColorScheme do end end end - - describe "destroy" do - it "also destroys old versions" do - c1 = described_class.create(valid_params.merge(version: 2)) - _c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1)) - _other = described_class.create(valid_params) - expect { - c1.destroy - }.to change { described_class.count }.by(-2) - end - end - - describe "#enabled" do - it "returns nil when there is no enabled record" do - expect(described_class.enabled).to eq nil - end - - it "returns the enabled color scheme" do - ColorScheme.hex_cache.clear - expect(described_class.hex_for_name('$primary_background_color')).to eq nil - c = described_class.create(valid_params.merge(enabled: true)) - expect(described_class.enabled.id).to eq c.id - expect(described_class.hex_for_name('$primary_background_color')).to eq "FFBB00" - end - end end diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb new file mode 100644 index 00000000000..2cdda9cd5b4 --- /dev/null +++ b/spec/models/remote_theme_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +describe RemoteTheme do + context '#import_remote' do + def setup_git_repo(files) + dir = Dir.tmpdir + repo_dir = "#{dir}/#{SecureRandom.hex}" + `mkdir #{repo_dir}` + `cd #{repo_dir} && git init .` + `cd #{repo_dir} && mkdir desktop mobile common` + files.each do |name, data| + File.write("#{repo_dir}/#{name}", data) + `cd #{repo_dir} && git add #{name}` + end + `cd #{repo_dir} && git commit -am 'first commit'` + repo_dir + end + + let :initial_repo do + setup_git_repo( + "about.json" => '{ + "name": "awesome theme", + "about_url": "https://www.site.com/about", + "license_url": "https://www.site.com/license" + }', + "desktop/desktop.scss" => "body {color: red;}", + "common/header.html" => "I AM HEADER", + "common/random.html" => "I AM SILLY", + ) + end + + after do + `rm -fr #{initial_repo}` + end + + it 'can correctly import a remote theme' do + + time = Time.new('2000') + freeze_time time + + @theme = RemoteTheme.import_theme(initial_repo) + remote = @theme.remote_theme + + expect(@theme.name).to eq('awesome theme') + expect(remote.remote_url).to eq(initial_repo) + expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + expect(remote.local_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + + expect(remote.about_url).to eq("https://www.site.com/about") + expect(remote.license_url).to eq("https://www.site.com/license") + + expect(@theme.theme_fields.length).to eq(2) + + mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target}-#{f.name}", f.value]}.flatten] + + expect(mapped["0-header"]).to eq("I AM HEADER") + expect(mapped["1-scss"]).to eq("body {color: red;}") + + expect(remote.remote_updated_at).to eq(time) + + File.write("#{initial_repo}/common/header.html", "I AM UPDATED") + `cd #{initial_repo} && git commit -am "update"` + + time = Time.new('2001') + freeze_time time + + remote.update_remote_version + expect(remote.commits_behind).to eq(1) + expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + + + remote.update_from_remote + @theme.save + @theme.reload + + mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target}-#{f.name}", f.value]}.flatten] + + expect(mapped["0-header"]).to eq("I AM UPDATED") + expect(mapped["1-scss"]).to eq("body {color: red;}") + expect(remote.remote_updated_at).to eq(time) + + end + end +end diff --git a/spec/models/site_customization_spec.rb b/spec/models/site_customization_spec.rb deleted file mode 100644 index e762c78d547..00000000000 --- a/spec/models/site_customization_spec.rb +++ /dev/null @@ -1,155 +0,0 @@ -require 'rails_helper' - -describe SiteCustomization do - - before do - SiteCustomization.clear_cache! - end - - let :user do - Fabricate(:user) - end - - let :customization_params do - {name: 'my name', user_id: user.id, header: "my awesome header", stylesheet: "my awesome css", mobile_stylesheet: nil, mobile_header: nil} - end - - let :customization do - SiteCustomization.create!(customization_params) - end - - let :customization_with_mobile do - SiteCustomization.create!(customization_params.merge(mobile_stylesheet: ".mobile {better: true;}", mobile_header: "fancy mobile stuff")) - end - - it 'should set default key when creating a new customization' do - s = SiteCustomization.create!(name: 'my name', user_id: user.id) - expect(s.key).not_to eq(nil) - end - - it 'can enable more than one style at once' do - c1 = SiteCustomization.create!(name: '2', user_id: user.id, header: 'World', - enabled: true, mobile_header: 'hi', footer: 'footer', - stylesheet: '.hello{.world {color: blue;}}') - - SiteCustomization.create!(name: '1', user_id: user.id, header: 'Hello', - enabled: true, mobile_footer: 'mfooter', - mobile_stylesheet: '.hello{margin: 1px;}', - stylesheet: 'p{width: 1px;}' - ) - - expect(SiteCustomization.custom_header).to eq("Hello\nWorld") - expect(SiteCustomization.custom_header(nil, :mobile)).to eq("hi") - expect(SiteCustomization.custom_footer(nil, :mobile)).to eq("mfooter") - expect(SiteCustomization.custom_footer).to eq("footer") - - desktop_css = SiteCustomization.custom_stylesheet - expect(desktop_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=desktop")) - - mobile_css = SiteCustomization.custom_stylesheet(nil, :mobile) - expect(mobile_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=mobile")) - - expect(SiteCustomization.enabled_stylesheet_contents).to match(/\.hello \.world/) - - # cache expiry - c1.enabled = false - c1.save - - expect(SiteCustomization.custom_stylesheet).not_to eq(desktop_css) - expect(SiteCustomization.enabled_stylesheet_contents).not_to match(/\.hello \.world/) - end - - it 'should be able to look up stylesheets by key' do - c = SiteCustomization.create!(name: '2', user_id: user.id, - enabled: true, - stylesheet: '.hello{.world {color: blue;}}', - mobile_stylesheet: '.world{.hello{color: black;}}') - - expect(SiteCustomization.custom_stylesheet(c.key, :mobile)).to match(Regexp.new("#{c.key}.css\\?target=mobile")) - expect(SiteCustomization.custom_stylesheet(c.key)).to match(Regexp.new("#{c.key}.css\\?target=desktop")) - - end - - - it 'should allow including discourse styles' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '@import "desktop";', mobile_stylesheet: '@import "mobile";') - expect(c.stylesheet_baked).not_to match(/Syntax error/) - expect(c.stylesheet_baked.length).to be > 1000 - expect(c.mobile_stylesheet_baked).not_to match(/Syntax error/) - expect(c.mobile_stylesheet_baked.length).to be > 1000 - end - - it 'should provide an awesome error on failure' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", header: '') - expect(c.stylesheet_baked).to match(/Syntax error/) - expect(c.mobile_stylesheet_baked).not_to be_present - end - - it 'should provide an awesome error on failure for mobile too' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '', header: '', mobile_stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", mobile_header: '') - expect(c.mobile_stylesheet_baked).to match(/Syntax error/) - expect(c.stylesheet_baked).not_to be_present - end - - it 'should correct bad html in body_tag_baked and head_tag_baked' do - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "I am bold", body_tag: "I am bold") - expect(c.head_tag_baked).to eq("I am bold") - expect(c.body_tag_baked).to eq("I am bold") - end - - it 'should precompile fragments in body and head tags' do - with_template = < - {{hello}} - - -HTML - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: with_template, body_tag: with_template) - expect(c.head_tag_baked).to match(/HTMLBars/) - expect(c.body_tag_baked).to match(/HTMLBars/) - expect(c.body_tag_baked).to match(/raw-handlebars/) - expect(c.head_tag_baked).to match(/raw-handlebars/) - end - - it 'should create body_tag_baked on demand if needed' do - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "test", enabled: true) - c.update_columns(head_tag_baked: nil) - expect(SiteCustomization.custom_head_tag).to match(/test<\/b>/) - end - - context "plugin api" do - def transpile(html) - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: html, body_tag: html) - c.head_tag_baked - end - - it "transpiles ES6 code" do - html = < - const x = 1; - -HTML - - transpiled = transpile(html) - expect(transpiled).to match(/\/) - expect(transpiled).to match(/var x = 1;/) - expect(transpiled).to match(/_registerPluginCode\('0.1'/) - end - - it "converts errors to a script type that is not evaluated" do - html = < - const x = 1; - x = 2; - -HTML - - transpiled = transpile(html) - expect(transpiled).to match(/text\/discourse-js-error/) - expect(transpiled).to match(/read-only/) - end - end - -end diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index d8bb189b343..8899269c808 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -2,6 +2,44 @@ require 'rails_helper' require_dependency 'site' describe Site do + + def expect_correct_themes(guardian) + json = Site.json_for(guardian) + parsed = JSON.parse(json) + + expected = 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}} + + expect(parsed["user_themes"]).to eq(expected) + end + + it "includes user themes and expires them as needed" do + default_theme = Theme.create!(user_id: -1, name: 'default') + SiteSetting.default_theme_key = default_theme.key + user_theme = Theme.create!(user_id: -1, name: 'user theme', user_selectable: true) + + anon_guardian = Guardian.new + user_guardian = Guardian.new(Fabricate(:user)) + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + Theme.clear_default! + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + user_theme.user_selectable = false + user_theme.save! + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + end + it "omits categories users can not write to from the category list" do category = Fabricate(:category) user = Fabricate(:user) diff --git a/spec/models/stylesheet_cache_spec.rb b/spec/models/stylesheet_cache_spec.rb index eb52d07bcc3..2c49fc36ba5 100644 --- a/spec/models/stylesheet_cache_spec.rb +++ b/spec/models/stylesheet_cache_spec.rb @@ -5,7 +5,7 @@ describe StylesheetCache do describe "add" do it "correctly cycles once MAX_TO_KEEP is hit" do (StylesheetCache::MAX_TO_KEEP + 1).times do |i| - StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s) + StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s, "map") end expect(StylesheetCache.count).to eq StylesheetCache::MAX_TO_KEEP @@ -13,8 +13,8 @@ describe StylesheetCache do end it "does nothing if digest is set and already exists" do - StylesheetCache.add("a", "b", "c") - StylesheetCache.add("a", "b", "cc") + StylesheetCache.add("a", "b", "c", "map") + StylesheetCache.add("a", "b", "cc", "map") expect(StylesheetCache.count).to eq 1 expect(StylesheetCache.first.content).to eq "c" diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb new file mode 100644 index 00000000000..6e11d7a5a5d --- /dev/null +++ b/spec/models/theme_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +describe Theme do + + before do + Theme.clear_cache! + end + + let :user do + Fabricate(:user) + end + + let :customization_params do + {name: 'my name', user_id: user.id, header: "my awesome header"} + end + + let :customization do + Theme.create!(customization_params) + end + + it 'should set default key when creating a new customization' do + s = Theme.create!(name: 'my name', user_id: user.id) + expect(s.key).not_to eq(nil) + end + + it 'can support child themes' do + child = Theme.new(name: '2', user_id: user.id) + + child.set_field(:common, "header", "World") + child.set_field(:desktop, "header", "Desktop") + child.set_field(:mobile, "header", "Mobile") + + child.save! + + expect(Theme.lookup_field(child.key, :desktop, "header")).to eq("World\nDesktop") + expect(Theme.lookup_field(child.key, "mobile", :header)).to eq("World\nMobile") + + + child.set_field(:common, "header", "Worldie") + child.save! + + expect(Theme.lookup_field(child.key, :mobile, :header)).to eq("Worldie\nMobile") + + parent = Theme.new(name: '1', user_id: user.id) + + parent.set_field(:common, "header", "Common Parent") + parent.set_field(:mobile, "header", "Mobile Parent") + + parent.save! + + parent.add_child_theme!(child) + + expect(Theme.lookup_field(parent.key, :mobile, "header")).to eq("Common Parent\nMobile Parent\nWorldie\nMobile") + + end + + it 'can correctly find parent themes' do + grandchild = Theme.create!(name: 'grandchild', user_id: user.id) + child = Theme.create!(name: 'child', user_id: user.id) + theme = Theme.create!(name: 'theme', user_id: user.id) + + theme.add_child_theme!(child) + child.add_child_theme!(grandchild) + + expect(grandchild.dependant_themes.length).to eq(2) + end + + + it 'should correct bad html in body_tag_baked and head_tag_baked' do + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(:common, "head_tag", "I am bold") + theme.save! + + expect(Theme.lookup_field(theme.key, :desktop, "head_tag")).to eq("I am bold") + end + + it 'should precompile fragments in body and head tags' do + with_template = < + {{hello}} + + +HTML + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(:common, "header", with_template) + theme.save! + + baked = Theme.lookup_field(theme.key, :mobile, "header") + + expect(baked).to match(/HTMLBars/) + expect(baked).to match(/raw-handlebars/) + end + + it 'should create body_tag_baked on demand if needed' do + + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(:common, :body_tag, "test") + theme.save + + ThemeField.update_all(value_baked: nil) + + expect(Theme.lookup_field(theme.key, :desktop, :body_tag)).to match(/test<\/b>/) + end + + context "plugin api" do + def transpile(html) + f = ThemeField.create!(target: Theme.targets[:mobile], theme_id: -1, name: "after_header", value: html) + f.value_baked + end + + it "transpiles ES6 code" do + html = < + const x = 1; + +HTML + + transpiled = transpile(html) + expect(transpiled).to match(/\/) + expect(transpiled).to match(/var x = 1;/) + expect(transpiled).to match(/_registerPluginCode\('0.1'/) + end + + it "converts errors to a script type that is not evaluated" do + html = < + const x = 1; + x = 2; + +HTML + + transpiled = transpile(html) + expect(transpiled).to match(/text\/discourse-js-error/) + expect(transpiled).to match(/read-only/) + end + end + + +end diff --git a/spec/services/color_scheme_revisor_spec.rb b/spec/services/color_scheme_revisor_spec.rb index d9f8a3519af..c2dc09d2c78 100644 --- a/spec/services/color_scheme_revisor_spec.rb +++ b/spec/services/color_scheme_revisor_spec.rb @@ -3,62 +3,42 @@ require 'rails_helper' describe ColorSchemeRevisor do let(:color) { Fabricate.build(:color_scheme_color, hex: 'FFFFFF', color_scheme: nil) } - let(:color_scheme) { Fabricate(:color_scheme, enabled: false, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) } - let(:valid_params) { { name: color_scheme.name, enabled: color_scheme.enabled, colors: nil } } + let(:color_scheme) { Fabricate(:color_scheme, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) } + let(:valid_params) { { name: color_scheme.name, colors: nil } } describe "revise" do it "does nothing if there are no changes" do expect { - described_class.revise(color_scheme, valid_params.merge(colors: nil)) + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: nil)) }.to_not change { color_scheme.reload.updated_at } end it "can change the name" do - described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(name: "Changed Name")) expect(color_scheme.reload.name).to eq("Changed Name") end - it "can update the theme_id" do - described_class.revise(color_scheme, valid_params.merge(theme_id: 'test')) - expect(color_scheme.reload.theme_id).to eq('test') + it "can update the base_scheme_id" do + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(base_scheme_id: 'test')) + expect(color_scheme.reload.base_scheme_id).to eq('test') end - it "can enable and disable" do - described_class.revise(color_scheme, valid_params.merge(enabled: true)) - expect(color_scheme.reload).to be_enabled - described_class.revise(color_scheme, valid_params.merge(enabled: false)) - expect(color_scheme.reload).not_to be_enabled - end - - def test_color_change(color_scheme_arg, expected_enabled) - described_class.revise(color_scheme_arg, valid_params.merge(colors: [ - {name: color.name, hex: 'BEEF99'} + it 'can change colors' do + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [ + {name: color.name, hex: 'BEEF99'}, + {name: 'bob', hex: 'AAAAAA'} ])) - color_scheme_arg.reload - expect(color_scheme_arg.enabled).to eq(expected_enabled) - expect(color_scheme_arg.colors.size).to eq(1) - expect(color_scheme_arg.colors.first.hex).to eq('BEEF99') - end + color_scheme.reload - it "can change colors of a color scheme that's not enabled" do - test_color_change(color_scheme, false) - end - - it "can change colors of the enabled color scheme" do - color_scheme.update_attribute(:enabled, true) - test_color_change(color_scheme, true) - end - - it "disables other color scheme before enabling" do - prev_enabled = Fabricate(:color_scheme, enabled: true) - described_class.revise(color_scheme, valid_params.merge(enabled: true)) - expect(prev_enabled.reload.enabled).to eq(false) - expect(color_scheme.reload.enabled).to eq(true) + expect(color_scheme.version).to eq(2) + expect(color_scheme.colors.size).to eq(2) + expect(color_scheme.colors.find_by(name: color.name).hex).to eq('BEEF99') + expect(color_scheme.colors.find_by(name: 'bob').hex).to eq('AAAAAA') end it "doesn't make changes when a color is invalid" do expect { - cs = described_class.revise(color_scheme, valid_params.merge(colors: [ + cs = ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [ {name: color.name, hex: 'OOPS'} ])) expect(cs).not_to be_valid @@ -66,72 +46,6 @@ describe ColorSchemeRevisor do }.to_not change { color_scheme.reload.version } expect(color_scheme.colors.first.hex).to eq(color.hex) end - - describe "versions" do - it "doesn't create a new version if colors is not given" do - expect { - described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) - }.to_not change { color_scheme.reload.version } - end - - it "creates a new version if colors have changed" do - old_hex = color.hex - expect { - described_class.revise(color_scheme, valid_params.merge(colors: [ - {name: color.name, hex: 'BEEF99'} - ])) - }.to change { color_scheme.reload.version }.by(1) - old_version = ColorScheme.find_by(versioned_id: color_scheme.id, version: (color_scheme.version - 1)) - expect(old_version).not_to eq(nil) - expect(old_version.colors.count).to eq(color_scheme.colors.count) - expect(old_version.colors_by_name[color.name].hex).to eq(old_hex) - expect(color_scheme.colors_by_name[color.name].hex).to eq('BEEF99') - end - - it "doesn't create a new version if colors have not changed" do - expect { - described_class.revise(color_scheme, valid_params.merge(colors: [ - {name: color.name, hex: color.hex} - ])) - }.to_not change { color_scheme.reload.version } - end - end - end - - describe "revert" do - context "when there are no previous versions" do - it "does nothing" do - expect { - expect(described_class.revert(color_scheme)).to eq(color_scheme) - }.to_not change { color_scheme.reload.version } - end - end - - context 'when there are previous versions' do - let(:new_color_params) { {name: color.name, hex: 'BEEF99'} } - - before do - @prev_hex = color.hex - described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ])) - end - - it "reverts the colors to the previous version" do - expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(new_color_params[:hex]) - expect { - described_class.revert(color_scheme) - }.to change { color_scheme.reload.version }.by(-1) - expect(color_scheme.colors.size).to eq(1) - expect(color_scheme.colors.first.hex).to eq(@prev_hex) - expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(@prev_hex) - end - - it "destroys the old version's record" do - expect { - described_class.revert(color_scheme) - }.to change { ColorScheme.count }.by(-1) - expect(color_scheme.reload.previous_version).to eq(nil) - end - end end end diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb index 26d7402c2a7..2be67fbfbff 100644 --- a/spec/services/staff_action_logger_spec.rb +++ b/spec/services/staff_action_logger_spec.rb @@ -129,46 +129,56 @@ describe StaffActionLogger do end end - describe "log_site_customization_change" do - let(:valid_params) { {name: 'Cool Theme', stylesheet: "body {\n background-color: blue;\n}\n", header: "h1 {color: white;}"} } + describe "log_theme_change" do it "raises an error when params are invalid" do - expect { logger.log_site_customization_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) + expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) + end + + let :theme do + Theme.new(name: 'bob', user_id: -1) end it "logs new site customizations" do - log_record = logger.log_site_customization_change(nil, valid_params) - expect(log_record.subject).to eq(valid_params[:name]) + + log_record = logger.log_theme_change(nil, theme) + expect(log_record.subject).to eq(theme.name) expect(log_record.previous_value).to eq(nil) expect(log_record.new_value).to be_present + json = ::JSON.parse(log_record.new_value) - expect(json['stylesheet']).to be_present - expect(json['header']).to be_present + expect(json['name']).to eq(theme.name) end it "logs updated site customizations" do - existing = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}") - log_record = logger.log_site_customization_change(existing, valid_params) + old_json = ThemeSerializer.new(theme, root:false).to_json + + theme.set_field(:common, :scss, "body{margin: 10px;}") + + log_record = logger.log_theme_change(old_json, theme) + expect(log_record.previous_value).to be_present - json = ::JSON.parse(log_record.previous_value) - expect(json['stylesheet']).to eq(existing.stylesheet) - expect(json['header']).to eq(existing.header) + + json = ::JSON.parse(log_record.new_value) + expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}"}]) end end - describe "log_site_customization_destroy" do + describe "log_theme_destroy" do it "raises an error when params are invalid" do - expect { logger.log_site_customization_destroy(nil) }.to raise_error(Discourse::InvalidParameters) + expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters) end it "creates a new UserHistory record" do - site_customization = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}") - log_record = logger.log_site_customization_destroy(site_customization) + theme = Theme.new(name: 'Banana') + theme.set_field(:common, :scss, "body{margin: 10px;}") + + log_record = logger.log_theme_destroy(theme) expect(log_record.previous_value).to be_present expect(log_record.new_value).to eq(nil) json = ::JSON.parse(log_record.previous_value) - expect(json['stylesheet']).to eq(site_customization.stylesheet) - expect(json['header']).to eq(site_customization.header) + + expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}"}]) end end diff --git a/test/stylesheets/test_helper.css b/test/stylesheets/test_helper.css index f0e5ac700c7..f9eddae24dc 100644 --- a/test/stylesheets/test_helper.css +++ b/test/stylesheets/test_helper.css @@ -1,7 +1,5 @@ -/* - *= require desktop - *= require_tree . -*/ +@import '/stylesheets/desktop.css'; + .modal-backdrop { display: none; }