Theming: a UI to choose some base colors that are applied to all the site css. CSS compiled outside of asset pipeline.

This commit is contained in:
Neil Lalonde 2014-05-02 17:46:03 -04:00
parent c97de2c449
commit c4d3aa3d47
46 changed files with 596 additions and 310 deletions

View file

@ -2,6 +2,8 @@
An input field for a color.
@param hexValue is a reference to the color's hex value.
@param brightnessValue is a number from 0 to 255 representing the brightness of the color. See ColorSchemeColor.
@params valid is a boolean indicating if the input field is a valid color.
@class Discourse.ColorInputComponent
@extends Ember.Component
@ -13,10 +15,12 @@ Discourse.ColorInputComponent = Ember.Component.extend({
hexValueChanged: function() {
var hex = this.get('hexValue');
if (hex && (hex.length === 3 || hex.length === 6) && this.get('brightnessValue')) {
if (this.get('valid')) {
this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';');
} else {
this.$('input').attr('style', '');
}
}.observes('hexValue', 'brightnessValue'),
}.observes('hexValue', 'brightnessValue', 'valid'),
didInsertElement: function() {
var self = this;

View file

@ -8,11 +8,10 @@
**/
Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
filter: null,
onlyOverridden: false,
baseColorScheme: function() {
return this.get('model').findBy('id', 1);
return this.get('model').findBy('is_base', true);
}.property('model.@each.id'),
baseColors: function() {
@ -28,15 +27,10 @@ Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
this.set('selectedItem', null);
},
filterContent: Discourse.debounce(function() {
filterContent: function() {
if (!this.get('selectedItem')) { return; }
var filter;
if (this.get('filter')) {
filter = this.get('filter').toLowerCase();
}
if ((filter === undefined || filter.length < 1) && !this.get('onlyOverridden')) {
if (!this.get('onlyOverridden')) {
this.set('colors', this.get('selectedItem.colors'));
return;
}
@ -44,18 +38,12 @@ Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
var matches = Em.A(), self = this, baseColor;
_.each(this.get('selectedItem.colors'), function(color){
if (filter === undefined || filter.length < 1 || color.get('name').toLowerCase().indexOf(filter) > -1) {
if (self.get('onlyOverridden')) {
baseColor = self.get('baseColors').get(color.get('name'));
if (color.get('hex') === baseColor.get('hex') && color.get('opacity') === baseColor.get('opacity')) {
return;
}
}
matches.pushObject(color);
}
baseColor = self.get('baseColors').get(color.get('name'));
if (color.get('hex') !== baseColor.get('hex')) matches.pushObject(color);
});
this.set('colors', matches);
}, 250).observes('filter', 'onlyOverridden'),
}.observes('onlyOverridden'),
actions: {
selectColorScheme: function(colorScheme) {
@ -64,6 +52,7 @@ Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
this.set('colors', colorScheme.get('colors'));
colorScheme.set('savingStatus', null);
colorScheme.set('selected', true);
this.filterContent();
},
newColorScheme: function() {
@ -71,10 +60,7 @@ Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
this.pushObject(newColorScheme);
this.send('selectColorScheme', newColorScheme);
},
clearFilter: function() {
this.set('filter', null);
this.set('onlyOverridden', false);
},
undo: function(color) {

View file

@ -27,7 +27,7 @@ Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
copy: function() {
var newScheme = Discourse.ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()});
_.each(this.get('colors'), function(c){
newScheme.colors.pushObject(Discourse.ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')}));
newScheme.colors.pushObject(Discourse.ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex')}));
});
return newScheme;
},
@ -40,7 +40,7 @@ Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
}.property('name', 'enabled', 'colors.@each.changed', 'saving'),
disableSave: function() {
return !this.get('changed') || this.get('saving');
return !this.get('changed') || this.get('saving') || _.any(this.get('colors'), function(c) { return !c.get('valid'); });
}.property('changed'),
newRecord: function() {
@ -48,6 +48,8 @@ Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
}.property('id'),
save: function() {
if (this.get('is_base') || this.get('disableSave')) return;
var self = this;
this.set('savingStatus', I18n.t('saving'));
this.set('saving',true);
@ -57,7 +59,7 @@ Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
data.colors = [];
_.each(this.get('colors'), function(c) {
if (!self.id || c.get('changed')) {
data.colors.pushObject({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')});
data.colors.pushObject({name: c.get('name'), hex: c.get('hex')});
}
});
@ -104,8 +106,8 @@ Discourse.ColorScheme.reopenClass({
id: colorScheme.id,
name: colorScheme.name,
enabled: colorScheme.enabled,
can_edit: colorScheme.can_edit,
colors: colorScheme.colors.map(function(c) { return Discourse.ColorSchemeColor.create({name: c.name, hex: c.hex, opacity: c.opacity}); })
is_base: colorScheme.is_base,
colors: colorScheme.colors.map(function(c) { return Discourse.ColorSchemeColor.create({name: c.name, hex: c.hex}); })
}));
});
colorSchemes.set('loading', false);

View file

@ -15,30 +15,24 @@ Discourse.ColorSchemeColor = Discourse.Model.extend({
},
startTrackingChanges: function() {
this.set('originals', {
hex: this.get('hex') || 'FFFFFF',
opacity: this.get('opacity') || '100'
});
this.set('originals', {hex: this.get('hex') || 'FFFFFF'});
this.notifyPropertyChange('hex'); // force changed property to be recalculated
},
changed: function() {
if (!this.originals) return false;
if (this.get('hex') !== this.originals['hex'] || this.get('opacity').toString() !== this.originals['opacity'].toString()) {
return true;
} else {
return false;
}
}.property('hex', 'opacity'),
if (this.get('hex') !== this.originals['hex']) return true;
return false;
}.property('hex'),
undo: function() {
if (this.originals) {
this.set('hex', this.originals['hex']);
this.set('opacity', this.originals['opacity']);
}
if (this.originals) this.set('hex', this.originals['hex']);
},
translatedName: function() {
return I18n.t('admin.customize.colors.' + this.get('name'));
}.property('name'),
/**
brightness returns a number between 0 (darkest) to 255 (brightest).
Undefined if hex is not a valid color.
@ -61,11 +55,7 @@ Discourse.ColorSchemeColor = Discourse.Model.extend({
}
}.observes('hex'),
opacityChanged: function() {
if (this.get('opacity')) {
var o = this.get('opacity').toString().replace(/[^\d.]/g, "");
if (parseInt(o,10) > 100) { o = o.substr(0,o.length-1); }
this.set('opacity', o);
}
}.observes('opacity')
valid: function() {
return this.get('hex').match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null;
}.property('hex')
});

View file

@ -8,6 +8,6 @@
**/
Discourse.AdminCustomizeIndexRoute = Discourse.Route.extend({
redirect: function() {
this.transitionTo('adminCustomize.css_html');
this.transitionTo('adminCustomize.colors');
}
});

View file

@ -4,9 +4,9 @@
<h3>{{i18n admin.customize.colors.long_title}}</h3>
<ul>
{{#each model}}
{{#if can_edit}}
{{#unless is_base}}
<li><a {{action selectColorScheme this}} {{bind-attr class="selected:active"}}>{{description}}</a></li>
{{/if}}
{{/unless}}
{{/each}}
</ul>
<button {{action newColorScheme}} class='btn'>{{i18n admin.customize.new}}</button>
@ -36,27 +36,22 @@
{{i18n admin.site_settings.show_overriden}}
</label>
</div>
<div class='controls'>
{{textField value=filter placeholderKey="type_to_filter"}}
<button {{action clearFilter}} class="btn">{{i18n admin.site_settings.clear_filter}}</button>
</div>
</div>
{{#if colors.length}}
<table class="table colors">
<thead>
<tr>
<th></th>
<th class="hex">{{i18n admin.customize.color}}</th>
<th class="opacity">{{i18n admin.customize.opacity}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each colors}}
<tr {{bind-attr class="changed"}}>
<td class="name">{{name}}</td>
<td class="hex">{{color-input hexValue=hex brightnessValue=brightness}}</td>
<td class="opacity">{{textField class="opacity-input" value=opacity maxlength="3"}}</td>
<tr {{bind-attr class="changed valid:valid:invalid"}}>
<td class="name" {{bind-attr title="name"}}>{{translatedName}}</td>
<td class="hex">{{color-input hexValue=hex brightnessValue=brightness valid=valid}}</td>
<td class="changed">
<button {{bind-attr class=":btn :undo changed::invisible"}} {{action undo this}}>undo</button>
</td>
@ -64,6 +59,9 @@
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n search.no_results}}</p>
{{/if}}
</div>
</div>
{{else}}

View file

@ -62,7 +62,8 @@ Discourse.addInitializer(function() {
if (!$(this).data('orig')) {
$(this).data('orig', this.href);
}
this.href = $(this).data('orig') + "&hash=" + me.hash;
var orig = $(this).data('orig');
this.href = orig + (orig.indexOf('?') >= 0 ? "&hash=" : "?hash=") + me.hash;
}
});
}

View file

@ -4,6 +4,7 @@
@import "common/foundation/helpers";
.admin-contents table {
width: 100%;
tr {text-align: left;}
@ -434,14 +435,18 @@ section.details {
.colors {
thead th { border: none; }
td.hex { width: 100px; }
td.changed { width: 300px; }
.hex-input { width: 80px; }
td.opacity { width: 50px; }
.opacity-input { width: 30px; }
.hex, .opacity { text-align: center; }
.hex { text-align: center; }
.changed .name {
color: scale-color($highlight, $lightness: -50%);
}
.invalid .hex input {
background-color: white;
color: black;
border-color: $danger;
}
}
}

View file

@ -77,18 +77,18 @@ body {
.clear-transitions {
@include transition(none !important);
}
form {
.tip {
display: inline-block;
&.good {
color: $success;
}
&.bad {
color: $danger;
}
.tip {
display: inline-block;
&.good {
color: $success;
}
&.bad {
color: $danger;
}
}
#wmd-input {
resize: none;
}

View file

@ -1,7 +1,9 @@
$primary: #333333 !default;
$secondary: #ffffff !default;
$tertiary: #0088cc !default;
$highlight: #ffff4d !default;
$danger: #e45735 !default;
$success: #009900 !default;
$love: #fa6c8d !default;
$primary: #333333 !default;
$secondary: #ffffff !default;
$tertiary: #0088cc !default;
$header_background: #ffffff !default;
$header_primary: #333333 !default;
$highlight: #ffff4d !default;
$danger: #e45735 !default;
$success: #009900 !default;
$love: #fa6c8d !default;

View file

@ -26,7 +26,6 @@ $base-font-size: 14px !default;
$base-line-height: 19px !default;
$base-font-family: Helvetica, Arial, sans-serif !default;
@import "colors";
/* This file doesn't actually exist, it is injected by DiscourseSassImporter. */
/* These files don't actually exist. They're injected by DiscourseSassImporter. */
@import "theme_variables";
@import "plugins_variables";

View file

@ -1,5 +1,18 @@
@import "common";
@import "desktop/*";
/* @import "desktop/*"; I don't know why this doesn't work. */
@import "desktop/compose";
@import "desktop/discourse";
@import "desktop/header";
@import "desktop/login";
@import "desktop/modal";
@import "desktop/poster_expansion";
@import "desktop/topic-list";
@import "desktop/topic-post";
@import "desktop/topic";
@import "desktop/upload";
@import "desktop/user";
/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */

View file

@ -8,8 +8,8 @@
top: 0;
left: 0;
z-index: 1000;
box-shadow: 0 2px 4px -2px rgba($primary, .25);
background-color: $secondary;
box-shadow: 0 2px 4px -2px rgba($header_primary, .25);
background-color: $header_background;
padding-top: 3px;
.docked & {
position: fixed;
@ -42,7 +42,7 @@
.current-username {
float: left;
a {
color: $primary;
color: $header_primary;
font-size: 14px;
display:block;
margin-top: 10px;
@ -65,7 +65,7 @@
.icon {
display: block;
padding: 3px;
color: scale-color($primary, $lightness: 50%);
color: scale-color($header_primary, $lightness: 50%);
text-decoration: none;
cursor: pointer;
border-top: 1px solid transparent;

View file

@ -528,12 +528,10 @@ iframe {
width: 78%;
max-width: 800px;
.topic-statuses {
i { color: $primary;
}
.unpinned { color: $primary;}
i { color: $header_primary; }
.unpinned { color: $header_primary; }
}
.topic-link {color: $primary;}
.topic-link { color: $header_primary; }
}

View file

@ -1,6 +1,17 @@
@import "common";
@import "mobile/*";
/* @import "mobile/*"; I don't know why this doesn't work */
@import "mobile/compose";
@import "mobile/discourse";
@import "mobile/faqs";
@import "mobile/header";
@import "mobile/login";
@import "mobile/modal";
@import "mobile/topic-list";
@import "mobile/topic-post";
@import "mobile/topic";
@import "mobile/upload";
@import "mobile/user";
/* These files doesn't actually exist, they are injected by DiscourseSassImporter. */

View file

@ -9,8 +9,8 @@
position: absolute;
top: 0;
z-index: 1001;
background-color: $secondary;
box-shadow: 0 0 3px rgba($primary, .6);
background-color: $header_background;
box-shadow: 0 0 3px rgba($header_primary, .6);
.docked & {
position: fixed;
@ -47,7 +47,7 @@
float: left;
display: none;
a {
color: darken($primary, 40%);
color: darken($header_primary, 40%);
font-size: 14px;
line-height: 40px;
}
@ -69,7 +69,7 @@
.icon {
display: block;
padding: 4px;
color: scale-color($primary, $lightness: 50%);
color: scale-color($header_primary, $lightness: 50%);
text-decoration: none;
cursor: pointer;
&:hover {

View file

@ -3,16 +3,25 @@ class Admin::ColorSchemesController < Admin::AdminController
before_filter :fetch_color_scheme, only: [:update, :destroy]
def index
render_serialized(ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer)
render_serialized([ColorScheme.base] + ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer)
end
def create
color_scheme = ColorScheme.create(color_scheme_params)
render json: color_scheme, root: false
if color_scheme.valid?
render json: color_scheme, root: false
else
render_json_error(color_scheme)
end
end
def update
render json: ColorSchemeRevisor.revise(@color_scheme, color_scheme_params), root: false
color_scheme = ColorSchemeRevisor.revise(@color_scheme, color_scheme_params)
if color_scheme.valid?
render json: color_scheme, root: false
else
render_json_error(color_scheme)
end
end
def destroy
@ -28,6 +37,6 @@ class Admin::ColorSchemesController < Admin::AdminController
end
def color_scheme_params
params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex, :opacity]])[:color_scheme]
params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex]])[:color_scheme]
end
end

View file

@ -1,5 +1,7 @@
class ColorScheme < ActiveRecord::Base
attr_accessor :is_base
has_many :color_scheme_colors, -> { order('id ASC') }, dependent: :destroy
alias_method :colors, :color_scheme_colors
@ -8,22 +10,41 @@ class ColorScheme < ActiveRecord::Base
after_destroy :destroy_versions
def self.enabled
current_version.find_by(enabled: true) || find(1)
validates_associated :color_scheme_colors
BASE_COLORS_FILE = "#{Rails.root}/app/assets/stylesheets/common/foundation/colors.scss"
@mutex = Mutex.new
def self.base_colors
@mutex.synchronize do
return @base_colors if @base_colors
@base_colors = {}
File.readlines(BASE_COLORS_FILE).each do |line|
matches = /\$([\w]+):\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})(?:[;]|\s)/.match(line.strip)
@base_colors[matches[1]] = matches[2] if matches
end
end
@base_colors
end
def can_edit?
self.id != 1 # base theme shouldn't be edited, except by seed data
def self.enabled
current_version.find_by(enabled: true)
end
def self.base
return @base_color if @base_color
@base_color = new(name: I18n.t('color_schemes.base_theme_name'), enabled: false)
@base_color.colors = base_colors.map { |name, hex| {name: name, hex: hex} }
@base_color.is_base = true
@base_color
end
def colors=(arr)
@colors_by_name = nil
arr.each do |c|
self.color_scheme_colors << ColorSchemeColor.new(
name: c[:name],
hex: c[:hex],
opacity: c[:opacity].to_i
)
self.color_scheme_colors << ColorSchemeColor.new( name: c[:name], hex: c[:hex] )
end
end
@ -36,7 +57,7 @@ class ColorScheme < ActiveRecord::Base
def colors_hashes
color_scheme_colors.map do |c|
{name: c.name, hex: c.hex, opacity: c.opacity}
{name: c.name, hex: c.hex}
end
end

View file

@ -1,101 +1,7 @@
class ColorSchemeColor < ActiveRecord::Base
belongs_to :color_scheme
BASE_COLORS = {
primary_border_color: "e6e6e6",
secondary_border_color: "f5f5f5",
tertiary_border_color: "ffffff",
highlight_border_color: "ffff4d",
emphasis_border_color: "00aaff",
warning_border_color: "f0a28f",
success_border_color: "009900",
primary_background_color: "ffffff",
secondary_background_color: "333333",
tertiary_background_color: "4d4d4d",
moderator_background_color: "ffffe5",
emphasis_background_color: "e5f6ff",
success_background_color: "99ff99",
warning_background_color: "f6c7bc",
highlight_background_color: "ffffc2",
like_background_color: "fee7ed",
composer_background_color: "e6e6e6",
notification_badge_background_color: "8c8c8c",
primary_text_color: "333333",
secondary_text_color: "999999",
tertiary_text_color: "ffffff",
warning_text_color: "e45735",
success_text_color: "009900",
emphasis_text_color: "00aaff",
highlight_text_color: "ffff4d",
like_color: "fa6c8d",
primary_shadow_color: "333333",
secondary_shadow_color: "ffffff",
warning_shadow_color: "f0a28f",
success_shadow_color: "009900",
highlight: "ffff4d",
header_item_highlight: "e5f6ff",
link_color: "0088cc",
secondary_link_color: "ffffff",
"muted-link-color" => "8c8c8c",
"muted-important-link-color" => "8c8c8c",
"link-color-visited" => "0088cc",
"link-color-hover" => "0088cc",
"link-color-active" => "0088cc",
heatmap_high: "ea7c62",
heatmap_med: "e45735",
heatmap_low: "cb3d1b",
coldmap_high: "33bbff",
coldmap_med: "00aaff",
coldmap_low: "0088cc",
"btn-default-color" => "333333",
"btn-default-background-color" => "b3b3b3",
"btn-default-background-color-hover" => "f5f5f5",
"btn-primary-border-color" => "0088cc",
"btn-primary-background-color" => "00aaff",
"btn-primary-background-color-dark" => "00aaff",
"btn-primary-background-color-light" => "99ddff",
"btn-danger-border-color" => "cb3d1b",
"btn-danger-background-color" => "e45735",
"btn-danger-background-color-dark" => "cb3d1b",
"btn-danger-background-color-light" => "e45735",
"btn-success-background" => "00b300",
"nav-pills-color" => "333333",
"nav-pills-color-hover" => "e45735",
"nav-pills-border-color-hover" => "f9dad2",
"nav-pills-background-color-hover" => "f9dad2",
"nav-pills-color-active" => "e45735",
"nav-pills-border-color-active" => "cb3d1b",
"nav-pills-background-color-active" => "e45735",
"nav-stacked-color" => "333333",
"nav-stacked-border-color" => "cccccc",
"nav-stacked-background-color" => "f5f5f5",
"nav-stacked-divider-color" => "cccccc",
"nav-stacked-chevron-color" => "b3b3b3",
"nav-stacked-border-color-active" => "e45735",
"nav-stacked-background-color-active" => "e45735",
"nav-button-color-hover" => "333333",
"nav-button-background-color-hover" => "cccccc",
"nav-button-color-active" => "333333",
"nav-button-background-color-active" => "cccccc",
"modal-header-color" => "e45735",
"modal-header-border-color" => "b3b3b3",
"modal-close-button-color" => "b3b3b3",
"nav-like-button-color-hover" => "fa6c8d",
"nav-like-button-background-color-hover" => "fed9e1",
"nav-like-button-color-active" => "f83b67",
"nav-like-button-background-color-active" => "fed9e1",
"topic-list-border-color" => "b3b3b3",
"topic-list-th-color" => "8c8c8c",
"topic-list-th-border-color" => "b3b3b3",
"topic-list-th-background-color" => "f5f5f5",
"topic-list-td-color" => "8c8c8c",
"topic-list-td-border-color" => "cccccc",
"topic-list-star-color" => "cccccc",
"topic-list-starred-color" => "e45735",
"quote-background" => "f5f5f5",
topicMenuColor: "333333",
bookmarkColor: "00aaff"
}
validates :hex, format: { with: /\A([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ }
end
# == Schema Information

View file

@ -1,4 +1,4 @@
require_dependency 'discourse_sass_importer'
require_dependency 'sass/discourse_sass_compiler'
class SiteCustomization < ActiveRecord::Base
ENABLED_KEY = '7e202ef2-56d7-47d5-98d8-a9c8d15e57dd'
@ -14,29 +14,7 @@ class SiteCustomization < ActiveRecord::Base
end
def compile_stylesheet(scss)
env = Rails.application.assets
# In production Rails.application.assets is a Sprockets::Index
# instead of Sprockets::Environment, there is no cleaner way
# to get the environment from the index.
if env.is_a?(Sprockets::Index)
env = env.instance_variable_get('@environment')
end
context = env.context_class.new(env, "custom.scss", "app/assets/stylesheets/custom.scss")
::Sass::Engine.new(scss, {
syntax: :scss,
cache: false,
read_cache: false,
style: :compressed,
filesystem_importer: DiscourseSassImporter,
sprockets: {
context: context,
environment: context.environment
}
}).render
DiscourseSassCompiler.compile(scss, 'custom')
rescue => e
puts e.backtrace.join("\n") unless Sass::SyntaxError === e
@ -49,13 +27,7 @@ class SiteCustomization < ActiveRecord::Base
begin
self.send("#{stylesheet_attr}_baked=", compile_stylesheet(self.send(stylesheet_attr)))
rescue Sass::SyntaxError => e
error = e.sass_backtrace_str("custom stylesheet")
error.gsub!("\n", '\A ')
error.gsub!("'", '\27 ')
self.send("#{stylesheet_attr}_baked=",
"footer { white-space: pre; }
footer:after { content: '#{error}' }")
self.send("#{stylesheet_attr}_baked=", DiscourseSassCompiler.error_as_css(e, "custom stylesheet"))
end
end
end

View file

@ -1,5 +1,5 @@
class ColorSchemeColorSerializer < ApplicationSerializer
attributes :name, :hex, :opacity
attributes :name, :hex
def hex
object.hex # otherwise something crazy is returned

View file

@ -1,9 +1,8 @@
class ColorSchemeSerializer < ApplicationSerializer
attributes :id, :name, :enabled, :can_edit
attributes :id, :name, :enabled, :is_base
has_many :colors, serializer: ColorSchemeColorSerializer, embed: :objects
def can_edit
object.can_edit?
def base
object.is_base || false
end
end

View file

@ -16,7 +16,7 @@ class ColorSchemeRevisor
def revise
ColorScheme.transaction do
if @params[:enabled]
ColorScheme.update_all enabled: false
ColorScheme.where('id != ?', @color_scheme.id).update_all enabled: false
end
@color_scheme.name = @params[:name]
@ -25,7 +25,7 @@ class ColorSchemeRevisor
if @params[:colors]
new_version = @params[:colors].any? do |c|
(existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex] or existing.opacity != c[:opacity]
(existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex]
end
end
@ -47,7 +47,7 @@ class ColorSchemeRevisor
end
end
@color_scheme.save!
@color_scheme.save
@color_scheme.clear_colors_cache
end
@color_scheme

View file

@ -1,9 +1,5 @@
<%- unless SiteCustomization.override_default_style(session[:preview_style]) %>
<% if mobile_view? %>
<%= stylesheet_link_tag "mobile" %>
<% else %>
<%= stylesheet_link_tag "desktop" %>
<% end %>
<%= DiscourseStylesheets.stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %>
<%- end %>
<%- if staff? %>

View file

@ -1,4 +1,6 @@
require_dependency 'discourse_sass_importer'
require_dependency 'sass/discourse_stylesheets'
require_dependency 'sass/discourse_sass_importer'
require_dependency 'sass/discourse_safe_sass_importer'
Sprockets.send(:remove_const, :SassImporter)
Sprockets::SassImporter = DiscourseSassImporter

View file

@ -1466,6 +1466,15 @@ en:
copy_name_prefix: "Copy of"
delete_confirm: "Delete this color scheme?"
under_construction: "NOTE: Changes made here will do nothing! This feature is under construction!"
primary: 'primary'
secondary: 'secondary'
tertiary: 'tertiary'
header_background: "header background"
header_primary: "header primary"
highlight: 'highlight'
danger: 'danger'
success: 'success'
love: 'love'
email:

View file

@ -211,6 +211,10 @@ en:
common: "is one of the 10000 most common passwords. Please use a more secure password."
ip_address:
signup_not_allowed: "Signup is not allowed from this account."
color_scheme_color:
attributes:
hex:
invalid: "is not a valid color"
user_profile:
no_info_me: "<div class='missing-profile'>the About Me field of your profile is currently blank, <a href='/users/%{username_lower}/preferences/about-me'>would you like to fill it out?</a></div>"

View file

@ -1,14 +0,0 @@
ColorScheme.seed do |s|
s.id = 1
s.name = I18n.t("color_schemes.base_theme_name")
s.enabled = false
end
ColorSchemeColor::BASE_COLORS.each_with_index do |color, i|
ColorSchemeColor.seed do |c|
c.id = i+1
c.name = color[0]
c.hex = color[1]
c.color_scheme_id = 1
end
end

View file

@ -0,0 +1,9 @@
class RemoveSeedColorScheme < ActiveRecord::Migration
def up
execute "DELETE FROM color_schemes WHERE id = 1"
execute "DELETE FROM color_scheme_colors WHERE color_scheme_id = 1"
end
def down
end
end

View file

@ -0,0 +1,9 @@
class RemoveOpacityFromColorSchemeColors < ActiveRecord::Migration
def up
remove_column :color_scheme_colors, :opacity
end
def down
add_column :color_scheme_colors, :opacity, :integer, null: false, default: 100
end
end

View file

@ -24,6 +24,11 @@ class Autospec::ReloadCss
end
def self.run_on_change(paths)
if paths.any? { |p| p =~ /\.(css|s[ac]ss)/ }
s = DiscourseStylesheets.new(:desktop) # TODO: what about mobile?
s.compile
paths << "public" + s.stylesheet_relpath_no_digest
end
paths.map! do |p|
hash = nil
fullpath = "#{Rails.root}/#{p}"

View file

@ -0,0 +1,32 @@
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

View file

@ -0,0 +1,55 @@
require_dependency 'sass/discourse_sass_importer'
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
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={})
env = Rails.application.assets
# In production Rails.application.assets is a Sprockets::Index
# instead of Sprockets::Environment, there is no cleaner way
# to get the environment from the index.
if env.is_a?(Sprockets::Index)
env = env.instance_variable_get('@environment')
end
context = env.context_class.new(env, "#{@target}.scss", "app/assets/stylesheets/#{@target}.scss")
::Sass::Engine.new(@scss, {
syntax: :scss,
cache: false,
read_cache: false,
style: Rails.env.production? ? :compressed : :expanded,
filesystem_importer: opts[:safe] ? DiscourseSafeSassImporter : DiscourseSassImporter,
sprockets: {
context: context,
environment: context.environment
}
}).render
end
end

View file

@ -31,7 +31,8 @@ class DiscourseSassImporter < Sass::Importers::Filesystem
"plugins" => DiscoursePluginRegistry.stylesheets,
"plugins_mobile" => DiscoursePluginRegistry.mobile_stylesheets,
"plugins_desktop" => DiscoursePluginRegistry.desktop_stylesheets,
"plugins_variables" => DiscoursePluginRegistry.sass_variables
"plugins_variables" => DiscoursePluginRegistry.sass_variables,
"theme_variables" => [ColorScheme::BASE_COLORS_FILE]
}
end
@ -45,21 +46,41 @@ class DiscourseSassImporter < Sass::Importers::Filesystem
def find(name, options)
if special_imports.has_key? name
stylesheets = special_imports[name]
contents = ""
stylesheets.each do |css_file|
if css_file =~ /\.scss$/
contents << "@import '#{css_file}';"
if name == "theme_variables"
contents = ""
if color_scheme = ColorScheme.enabled
ColorScheme.base_colors.each do |name, base_hex|
override = color_scheme.colors_by_name[name]
contents << "$#{name}: ##{override ? override.hex : base_hex} !default;\n"
end
else
contents << File.read(css_file)
special_imports[name].each do |css_file|
contents << File.read(css_file)
end
end
depend_on(css_file)
Sass::Engine.new(contents, options.merge(
filename: "#{name}.scss",
importer: self,
syntax: :scss
))
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
Sass::Engine.new(contents, options.merge(
filename: "#{name}.scss",
importer: self,
syntax: :scss
))
end
Sass::Engine.new(contents, options.merge(
filename: "#{name}.scss",
importer: self,
syntax: :scss
))
elsif name =~ GLOB
nil # globs must be relative
else

View file

@ -0,0 +1,95 @@
require_dependency 'sass/discourse_sass_compiler'
class DiscourseStylesheets
CACHE_PATH = 'uploads/stylesheet-cache'
@lock = Mutex.new
def self.stylesheet_link_tag(target = :desktop)
builder = self.new(target)
@lock.synchronize do
builder.compile unless File.exists?(builder.stylesheet_fullpath)
builder.ensure_digestless_file
%[<link href="#{Rails.env.production? ? builder.stylesheet_relpath : builder.stylesheet_relpath_no_digest + '?body=1'}" media="screen" rel="stylesheet" />].html_safe
end
end
def self.compile(target = :desktop)
@lock.synchronize do
builder = self.new(target)
builder.compile
builder.stylesheet_filename
end
end
def initialize(target = :desktop)
@target = target
end
def compile
scss = File.read("#{Rails.root}/app/assets/stylesheets/#{@target}.scss")
css = begin
DiscourseSassCompiler.compile(scss, @target)
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
css
end
def ensure_digestless_file
unless 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 cache_fullpath
"#{Rails.root}/public/#{CACHE_PATH}"
end
def stylesheet_fullpath
"#{cache_fullpath}/#{stylesheet_filename}"
end
def stylesheet_fullpath_no_digest
"#{cache_fullpath}/#{stylesheet_filename_no_digest}"
end
def stylesheet_relpath
"/#{CACHE_PATH}/#{stylesheet_filename}"
end
def stylesheet_relpath_no_digest
"/#{CACHE_PATH}/#{stylesheet_filename_no_digest}"
end
def stylesheet_filename
"#{@target}_#{digest}.css"
end
def stylesheet_filename_no_digest
"#{@target}.css"
end
def digest
@digest ||= begin
# Watch for file changes unless in production env.
# In production, file changes only happen during deploy, followed by assets:precompile.
last_file_updated = if Rails.env.production?
0
else
[ Dir.glob("#{Rails.root}/app/assets/stylesheets/**/*.*css").map {|x| File.mtime(x) }.max,
Dir.glob("#{Rails.root}/plugins/**/assets/stylesheets/**/*.*css").map {|x| File.mtime(x) }.max ].max.to_i
end
theme = (cs = ColorScheme.enabled) ? "#{cs.id}-#{cs.version}" : 0
# digest encodes the things that trigger a recompile
Digest::SHA1.hexdigest("#{RailsMultisite::ConnectionManagement.current_db}-#{theme}-#{last_file_updated}")
end
end
end

View file

@ -44,4 +44,16 @@ task 'assets:precompile:before' do
end
task 'assets:precompile' => 'assets:precompile:before'
task 'assets:precompile:css' => 'environment' do
RailsMultisite::ConnectionManagement.each_connection do |db|
puts "Compiling css for #{db}"
[:desktop, :mobile].each do |target|
puts DiscourseStylesheets.compile(target)
end
end
end
task 'assets:precompile' => 'assets:precompile:before' do
# Run after assets:precompile
Rake::Task["assets:precompile:css"].invoke
end

View file

@ -0,0 +1,30 @@
require 'spec_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")
css.should include("color")
css.should 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)
css.should_not include('my-plugin-thing')
end
end
end

View file

@ -0,0 +1,32 @@
require 'spec_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)
builder.compile.should 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)
builder.compile.should 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)
builder.compile.should_not include('my-plugin-thing')
FileUtils.rm builder.stylesheet_fullpath
end
end
end

View file

@ -11,8 +11,8 @@ describe Admin::ColorSchemesController do
name: 'Such Design',
enabled: true,
colors: [
{name: '$primary_background_color', hex: 'FFBB00', opacity: '100'},
{name: '$secondary_background_color', hex: '888888', opacity: '70'}
{name: 'primary', hex: 'FFBB00'},
{name: 'secondary', hex: '888888'}
]
}
} }
@ -40,6 +40,14 @@ describe Admin::ColorSchemesController do
xhr :post, :create, valid_params
::JSON.parse(response.body)['id'].should be_present
end
it "returns failure with invalid params" do
params = valid_params
params[:color_scheme][:colors][0][:hex] = 'cool color please'
xhr :post, :create, valid_params
response.should_not be_success
::JSON.parse(response.body)['errors'].should be_present
end
end
describe "update" do
@ -56,6 +64,16 @@ describe Admin::ColorSchemesController do
xhr :put, :update, valid_params.merge(id: existing.id)
::JSON.parse(response.body)['id'].should be_present
end
it "returns failure with invalid params" do
color_scheme = Fabricate(:color_scheme)
params = valid_params.merge(id: color_scheme.id)
params[:color_scheme][:colors][0][:name] = color_scheme.colors.first.name
params[:color_scheme][:colors][0][:hex] = 'cool color please'
xhr :put, :update, params
response.should_not be_success
::JSON.parse(response.body)['errors'].should be_present
end
end
describe "destroy" do

View file

@ -1,6 +1,5 @@
Fabricator(:color_scheme_color) do
color_scheme
name { sequence(:name) {|i| "$color_#{i}" } }
name { sequence(:name) {|i| "color_#{i}" } }
hex "333333"
opacity 100
end

1
spec/fixtures/scss/broken.scss vendored Normal file
View file

@ -0,0 +1 @@
this isn't valid scss, so it will fail to compile

3
spec/fixtures/scss/my_plugin.scss vendored Normal file
View file

@ -0,0 +1,3 @@
.my-plugin-thing {
color: blue
}

View file

@ -0,0 +1,18 @@
require 'spec_helper'
describe ColorSchemeColor do
def test_invalid_hex(hex)
c = described_class.new(hex: hex)
c.should_not be_valid
c.errors[:hex].should be_present
end
it "validates hex value" do
['fff', 'ffffff', '333333', '333', '0BeeF0'].each do |hex|
described_class.new(hex: hex).should be_valid
end
['fffff', 'ffff', 'ff', 'f', '00000', '00', 'cheese', '#666666', '#666', '555 666'].each do |hex|
test_invalid_hex(hex)
end
end
end

View file

@ -2,10 +2,28 @@ require 'spec_helper'
describe ColorScheme do
describe '#base_colors' do
it 'parses the colors.scss file and returns a hash' do
File.stubs(:readlines).with(described_class::BASE_COLORS_FILE).returns([
'$primary: #333333 !default;',
'$secondary: #ffffff !default; ',
'$highlight: #ffff4d;',
' $danger:#e45735 !default;',
])
colors = described_class.base_colors
colors.should be_a(Hash)
colors['primary'].should == '333333'
colors['secondary'].should == 'ffffff'
colors['highlight'].should == 'ffff4d'
colors['danger'].should == 'e45735'
end
end
let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} }
let(:valid_colors) { [
{name: '$primary_background_color', hex: 'FFBB00', opacity: '100'},
{name: '$secondary_background_color', hex: '888888', opacity: '70'}
{name: '$primary_background_color', hex: 'FFBB00'},
{name: '$secondary_background_color', hex: '888888'}
]}
describe "new" do
@ -31,8 +49,8 @@ describe ColorScheme do
end
describe "#enabled" do
it "returns the base color scheme when there is no enabled record" do
described_class.enabled.id.should == 1
it "returns nil when there is no enabled record" do
described_class.enabled.should be_nil
end
it "returns the enabled color scheme" do

View file

@ -155,12 +155,12 @@ describe SiteCustomization do
it 'should compile scss' do
c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '$black: #000; #a { color: $black; }', header: '')
c.stylesheet_baked.should == "#a{color:#000}\n"
["#a{color:#000;}", "#a{color:black;}"].should include(c.stylesheet_baked.gsub(' ', '').gsub("\n", ''))
end
it 'should compile mobile scss' do
c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '', header: '', mobile_stylesheet: '$black: #000; #a { color: $black; }', mobile_header: '')
c.mobile_stylesheet_baked.should == "#a{color:#000}\n"
["#a{color:#000;}", "#a{color:black;}"].should include(c.mobile_stylesheet_baked.gsub(' ', '').gsub("\n", ''))
end
it 'should allow including discourse styles' do

View file

@ -25,14 +25,23 @@ describe ColorSchemeRevisor do
color_scheme.reload.should_not be_enabled
end
it "can change colors" do
described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99', opacity: 99}
def test_color_change(color_scheme_arg, expected_enabled)
described_class.revise(color_scheme_arg, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99'}
]))
color_scheme.reload
color_scheme.colors.size.should == 1
color_scheme.colors.first.hex.should == 'BEEF99'
color_scheme.colors.first.opacity.should == 99
color_scheme_arg.reload
color_scheme_arg.enabled.should == expected_enabled
color_scheme_arg.colors.size.should == 1
color_scheme_arg.colors.first.hex.should == 'BEEF99'
end
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
@ -42,6 +51,17 @@ describe ColorSchemeRevisor do
color_scheme.reload.enabled.should == true
end
it "doesn't make changes when a color is invalid" do
expect {
cs = described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'OOPS'}
]))
cs.should_not be_valid
cs.errors.should be_present
}.to_not change { color_scheme.reload.version }
color_scheme.colors.first.hex.should == color.hex
end
describe "versions" do
it "doesn't create a new version if colors is not given" do
expect {
@ -50,25 +70,23 @@ describe ColorSchemeRevisor do
end
it "creates a new version if colors have changed" do
old_hex, old_opacity = color.hex, color.opacity
old_hex = color.hex
expect {
described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99', opacity: 99}
{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))
old_version.should_not be_nil
old_version.colors.count.should == color_scheme.colors.count
old_version.colors_by_name[color.name].hex.should == old_hex
old_version.colors_by_name[color.name].opacity.should == old_opacity
color_scheme.colors_by_name[color.name].hex.should == 'BEEF99'
color_scheme.colors_by_name[color.name].opacity.should == 99
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, opacity: color.opacity}
{name: color.name, hex: color.hex}
]))
}.to_not change { color_scheme.reload.version }
end
@ -85,10 +103,10 @@ describe ColorSchemeRevisor do
end
context 'when there are previous versions' do
let(:new_color_params) { {name: color.name, hex: 'BEEF99', opacity: 99} }
let(:new_color_params) { {name: color.name, hex: 'BEEF99'} }
before do
@prev_hex, @prev_opacity = color.hex, color.opacity
@prev_hex = color.hex
described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ]))
end
@ -99,9 +117,7 @@ describe ColorSchemeRevisor do
}.to change { color_scheme.reload.version }.by(-1)
color_scheme.colors.size.should == 1
color_scheme.colors.first.hex.should == @prev_hex
color_scheme.colors.first.opacity.should == @prev_opacity
color_scheme.colors_by_name[new_color_params[:name]].hex.should == @prev_hex
color_scheme.colors_by_name[new_color_params[:name]].opacity.should == @prev_opacity
end
it "destroys the old version's record" do