Theming: color scheme editing. Unfinished! Doesn't have any effect on css files yet.

This commit is contained in:
Neil Lalonde 2014-04-16 09:49:06 -04:00
parent 0f4014eef1
commit feaaf55a0c
34 changed files with 1086 additions and 84 deletions

View File

@ -0,0 +1,28 @@
/**
An input field for a color.
@param hexValue is a reference to the color's hex value.
@class Discourse.ColorInputComponent
@extends Ember.Component
@namespace Discourse
@module Discourse
**/
Discourse.ColorInputComponent = Ember.Component.extend({
layoutName: 'components/color-input',
hexValueChanged: function() {
var hex = this.get('hexValue');
if (hex && (hex.length === 3 || hex.length === 6) && this.get('brightnessValue')) {
this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';');
}
}.observes('hexValue', 'brightnessValue'),
didInsertElement: function() {
var self = this;
this._super();
Em.run.schedule('afterRender', function() {
self.hexValueChanged();
});
}
});

View File

@ -0,0 +1,76 @@
/**
This controller supports interface for creating custom CSS skins in Discourse.
@class AdminCustomizeColorsController
@extends Ember.Controller
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
baseColorScheme: function() {
return this.get('model').findBy('id', 1);
}.property('model.@each.id'),
removeSelected: function() {
this.removeObject(this.get('selectedItem'));
this.set('selectedItem', null);
},
actions: {
selectColorScheme: function(colorScheme) {
if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
this.set('selectedItem', colorScheme);
colorScheme.set('savingStatus', null);
colorScheme.set('selected', true);
},
newColorScheme: function() {
var newColorScheme = Em.copy(this.get('baseColorScheme'), true);
newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
this.pushObject(newColorScheme);
this.send('selectColorScheme', newColorScheme);
},
undo: function(color) {
color.undo();
},
save: function() {
var selectedItem = this.get('selectedItem');
selectedItem.save();
if (selectedItem.get('enabled')) {
this.get('model').forEach(function(c) {
if (c !== selectedItem) {
c.set('enabled', false);
c.startTrackingChanges();
c.notifyPropertyChange('description');
}
});
}
},
copy: function(colorScheme) {
var newColorScheme = Em.copy(colorScheme, true);
newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name'));
this.pushObject(newColorScheme);
this.send('selectColorScheme', newColorScheme);
},
destroy: function() {
var self = this,
item = self.get('selectedItem');
return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
if (item.get('newRecord')) {
self.removeSelected();
} else {
item.destroy().then(function(){ self.removeSelected(); });
}
}
});
}
}
});

View File

@ -1,12 +1,12 @@
/**
This controller supports interface for creating custom CSS skins in Discourse.
@class AdminCustomizeController
@class AdminCustomizeCssHtmlController
@extends Ember.Controller
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeController = Ember.ArrayController.extend({
Discourse.AdminCustomizeCssHtmlController = Ember.ArrayController.extend({
actions: {

View File

@ -0,0 +1,115 @@
/**
Our data model for a color scheme.
@class ColorScheme
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
init: function() {
this._super();
this.startTrackingChanges();
},
description: function() {
return "" + this.name + (this.enabled ? ' (*)' : '');
}.property(),
startTrackingChanges: function() {
this.set('originals', {
name: this.get('name'),
enabled: this.get('enabled')
});
},
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')}));
});
return newScheme;
},
changed: function() {
if (!this.originals) return false;
if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true;
if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true;
return false;
}.property('name', 'enabled', 'colors.@each.changed', 'saving'),
disableSave: function() {
return !this.get('changed') || this.get('saving');
}.property('changed'),
newRecord: function() {
return (!this.get('id'));
}.property('id'),
save: function() {
var self = this;
this.set('savingStatus', I18n.t('saving'));
this.set('saving',true);
var data = { name: this.name, enabled: this.enabled };
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')});
}
});
return Discourse.ajax("/admin/color_schemes" + (this.id ? '/' + this.id : '') + '.json', {
data: JSON.stringify({"color_scheme": data}),
type: this.id ? 'PUT' : 'POST',
dataType: 'json',
contentType: 'application/json'
}).then(function(result) {
if(result.id) { self.set('id', result.id); }
self.startTrackingChanges();
_.each(self.get('colors'), function(c) {
c.startTrackingChanges();
});
self.set('savingStatus', I18n.t('saved'));
self.set('saving', false);
self.notifyPropertyChange('description');
});
},
destroy: function() {
if (this.id) {
return Discourse.ajax("/admin/color_schemes/" + this.id, { type: 'DELETE' });
}
}
});
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')
});
Discourse.ColorScheme.reopenClass({
findAll: function() {
var colorSchemes = ColorSchemes.create({ content: [], loading: true });
Discourse.ajax('/admin/color_schemes').then(function(all) {
_.each(all, function(colorScheme){
colorSchemes.pushObject(Discourse.ColorScheme.create({
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}); })
}));
});
colorSchemes.set('loading', false);
});
return colorSchemes;
}
});

View File

@ -0,0 +1,71 @@
/**
Our data model for a color within a color scheme.
(It's a funny name for a class, but Color seemed too generic for what this class is.)
@class ColorSchemeColor
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.ColorSchemeColor = Discourse.Model.extend({
init: function() {
this._super();
this.startTrackingChanges();
},
startTrackingChanges: function() {
this.set('originals', {
hex: this.get('hex') || 'FFFFFF',
opacity: this.get('opacity') || '100'
});
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'),
undo: function() {
if (this.originals) {
this.set('hex', this.originals['hex']);
this.set('opacity', this.originals['opacity']);
}
},
/**
brightness returns a number between 0 (darkest) to 255 (brightest).
Undefined if hex is not a valid color.
@property brightness
**/
brightness: function() {
var hex = this.get('hex');
if (hex.length === 6 || hex.length === 3) {
if (hex.length === 3) {
hex = hex.substr(0,1) + hex.substr(0,1) + hex.substr(1,1) + hex.substr(1,1) + hex.substr(2,1) + hex.substr(2,1);
}
return Math.round(((parseInt('0x'+hex.substr(0,2)) * 299) + (parseInt('0x'+hex.substr(2,2)) * 587) + (parseInt('0x'+hex.substr(4,2)) * 114)) /1000);
}
}.property('hex'),
hexValueChanged: function() {
if (this.get('hex')) {
this.set('hex', this.get('hex').toString().replace(/[^0-9a-fA-F]/g, ""));
}
}.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')
});

View File

@ -0,0 +1,20 @@
/**
Handles routes related to colors customization
@class AdminCustomizeColorsRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeColorsRoute = Discourse.Route.extend({
model: function() {
return Discourse.ColorScheme.findAll();
},
deactivate: function() {
this._super();
this.controllerFor('adminCustomizeColors').set('selectedItem', null);
},
});

View File

@ -0,0 +1,15 @@
/**
Handles routes related to css/html customization
@class AdminCustomizeCssHtmlRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeCssHtmlRoute = Discourse.Route.extend({
model: function() {
return Discourse.SiteCustomization.findAll();
}
});

View File

@ -1,15 +1,13 @@
/**
Handles routes related to customization
@class AdminCustomizeRoute
@class AdminCustomizeIndexRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeRoute = Discourse.Route.extend({
model: function() {
return Discourse.SiteCustomization.findAll();
Discourse.AdminCustomizeIndexRoute = Discourse.Route.extend({
redirect: function() {
this.transitionTo('adminCustomize.css_html');
}
});
});

View File

@ -22,7 +22,10 @@ Discourse.Route.buildRoutes(function() {
this.route('previewDigest', { path: '/preview-digest' });
});
this.route('customize');
this.resource('adminCustomize', { path: '/customize' } ,function() {
this.route('colors');
this.route('css_html');
});
this.route('api');
this.resource('admin.backups', { path: '/backups' }, function() {

View File

@ -20,7 +20,7 @@
<li>{{#link-to 'adminFlags'}}{{i18n admin.flags.title}}{{/link-to}}</li>
<li>{{#link-to 'adminLogs'}}{{i18n admin.logs.title}}{{/link-to}}</li>
{{#if currentUser.admin}}
<li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li>
{{/if}}

View File

@ -1,70 +1,12 @@
<div class='content-list span6'>
<h3>{{i18n admin.customize.long_title}}</h3>
<ul>
{{#each model}}
<li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li>
{{/each}}
</ul>
<button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button>
<div class='admin-controls'>
<div class='span15'>
<ul class="nav nav-pills">
<li>{{#link-to 'adminCustomize.colors'}}{{i18n admin.customize.colors.title}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomize.css_html'}}{{i18n admin.customize.css_html.title}}{{/link-to}}</li>
</ul>
</div>
</div>
{{#if selectedItem}}
<div class='current-style'>
{{#with selectedItem}}
{{textField class="style-name" value=name}}
<div class='admin-controls'>
<ul class="nav nav-pills">
<li>
<a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
</li>
<li>
<a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
</li>
<li>
<a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a>
</li>
<li>
<a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a>
</li>
</ul>
</div>
<div class="admin-container">
{{#if view.headerActive}}
{{aceEditor content=header mode="html"}}
{{/if}}
{{#if view.stylesheetActive}}
{{aceEditor content=stylesheet mode="scss"}}
{{/if}}
{{#if view.mobileHeaderActive}}
{{aceEditor content=mobile_header mode="html"}}
{{/if}}
{{#if view.mobileStylesheetActive}}
{{aceEditor content=mobile_stylesheet mode="scss"}}
{{/if}}
</div>
{{/with}}
<br>
<div class='status-actions'>
<span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span>
<span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span>
{{#unless selectedItem.changed}}
<a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
|
<a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
{{/unless}}
</div>
<div class='buttons'>
<button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
<span class='saving'>{{selectedItem.savingStatus}}</span>
<a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
</div>
<div class="admin-container">
{{outlet}}
</div>
{{else}}
<p class="about">{{i18n admin.customize.about}}</p>
{{/if}}
<div class='clearfix'></div>

View File

@ -0,0 +1,56 @@
<div class='alert alert-error'>{{i18n admin.customize.colors.under_construction}}</div>
<div class='content-list span6'>
<h3>{{i18n admin.customize.colors.long_title}}</h3>
<ul>
{{#each model}}
{{#if can_edit}}
<li><a {{action selectColorScheme this}} {{bind-attr class="selected:active"}}>{{description}}</a></li>
{{/if}}
{{/each}}
</ul>
<button {{action newColorScheme}} class='btn'>{{i18n admin.customize.new}}</button>
</div>
{{#if selectedItem}}
{{#with selectedItem}}
<div class="current-style color-scheme">
<div class="admin-container">
<h1>{{textField class="style-name" value=name}}</h1>
<div class="controls">
<button {{action save}} {{bind-attr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
<span {{bind-attr class=":saving savingStatus::hidden" }}>{{savingStatus}}</span>
<button {{action copy this}} class='btn'><i class="fa fa-copy"></i> {{i18n admin.customize.copy}}</button>
<span>{{view Ember.Checkbox checkedBinding="enabled"}} {{i18n admin.customize.enabled}}</span>
<a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
</div>
<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>
<td class="changed">
<button {{bind-attr class=":btn :undo changed::invisible"}} {{action undo this}}>undo</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
{{/with}}
{{else}}
<p class="about">{{i18n admin.customize.colors.about}}</p>
{{/if}}

View File

@ -0,0 +1,70 @@
<div class='content-list span6'>
<h3>{{i18n admin.customize.css_html.long_title}}</h3>
<ul>
{{#each model}}
<li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li>
{{/each}}
</ul>
<button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button>
</div>
{{#if selectedItem}}
<div class='current-style'>
{{#with selectedItem}}
{{textField class="style-name" value=name}}
<div class='admin-controls'>
<ul class="nav nav-pills">
<li>
<a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
</li>
<li>
<a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
</li>
<li>
<a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a>
</li>
<li>
<a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a>
</li>
</ul>
</div>
<div class="admin-container">
{{#if view.headerActive}}
{{aceEditor content=header mode="html"}}
{{/if}}
{{#if view.stylesheetActive}}
{{aceEditor content=stylesheet mode="scss"}}
{{/if}}
{{#if view.mobileHeaderActive}}
{{aceEditor content=mobile_header mode="html"}}
{{/if}}
{{#if view.mobileStylesheetActive}}
{{aceEditor content=mobile_stylesheet mode="scss"}}
{{/if}}
</div>
{{/with}}
<br>
<div class='status-actions'>
<span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span>
<span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span>
{{#unless selectedItem.changed}}
<a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
|
<a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
{{/unless}}
</div>
<div class='buttons'>
<button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
<span class='saving'>{{selectedItem.savingStatus}}</span>
<a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
</div>
</div>
{{else}}
<p class="about">{{i18n admin.customize.about}}</p>
{{/if}}
<div class='clearfix'></div>

View File

@ -0,0 +1,11 @@
/**
A view to handle color selections within a site customization
@class AdminCustomizeColorsView
@extends Discourse.View
@namespace Discourse
@module Discourse
**/
Discourse.AdminCustomizeColorsView = Discourse.View.extend({
templateName: 'admin/templates/customize_colors'
});

View File

@ -12,15 +12,14 @@ Discourse.AdminCustomizeView = Discourse.View.extend({
templateName: 'admin/templates/customize',
classNames: ['customize'],
selected: 'stylesheet',
headerActive: Em.computed.equal('selected', 'header'),
stylesheetActive: Em.computed.equal('selected', 'stylesheet'),
headerActive: Em.computed.equal('selected', 'header'),
stylesheetActive: Em.computed.equal('selected', 'stylesheet'),
mobileHeaderActive: Em.computed.equal('selected', 'mobileHeader'),
mobileStylesheetActive: Em.computed.equal('selected', 'mobileStylesheet'),
actions: {
selectHeader: function() { this.set('selected', 'header'); },
selectStylesheet: function() { this.set('selected', 'stylesheet'); },
selectHeader: function() { this.set('selected', 'header'); },
selectStylesheet: function() { this.set('selected', 'stylesheet'); },
selectMobileHeader: function() { this.set('selected', 'mobileHeader'); },
selectMobileStylesheet: function() { this.set('selected', 'mobileStylesheet'); }
},

View File

@ -0,0 +1 @@
{{textField class="hex-input" value=hexValue maxlength="6"}}

View File

@ -421,6 +421,25 @@ section.details {
}
}
}
.color-scheme {
.controls {
span, button, a {
margin-right: 10px;
}
}
}
.colors {
thead th { border: none; }
td.hex { width: 100px; }
.hex-input { width: 80px; }
td.opacity { width: 50px; }
.opacity-input { width: 30px; }
.hex, .opacity { text-align: center; }
.changed .name {
color: darken($highlight_text_color, 30%);
}
}
}
@ -790,12 +809,16 @@ table.api-keys {
color: $primary_text_color;
font-size: 15px;
padding-left: 5px;
margin-bottom: 10px;
}
ul {
list-style: none;
margin: 0;
li:first-of-type {
border-top: 1px solid $primary_border_color;
}
li {
border-bottom: 1px solid $primary_border_color;
}

View File

@ -0,0 +1,33 @@
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)
end
def create
color_scheme = ColorScheme.create(color_scheme_params)
render json: color_scheme, root: false
end
def update
render json: ColorSchemeRevisor.revise(@color_scheme, color_scheme_params), root: false
end
def destroy
@color_scheme.destroy
render json: success_json
end
private
def fetch_color_scheme
@color_scheme = ColorScheme.find(params[:id])
end
def color_scheme_params
params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex, :opacity]])[:color_scheme]
end
end

View File

@ -0,0 +1,51 @@
class ColorScheme < ActiveRecord::Base
has_many :color_scheme_colors, -> { order('id ASC') }, dependent: :destroy
alias_method :colors, :color_scheme_colors
scope :current_version, ->{ where(versioned_id: nil) }
after_destroy :destroy_versions
def self.enabled
current_version.where(enabled: true).first || find(1)
end
def can_edit?
self.id != 1 # base theme shouldn't be edited, except by seed data
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
)
end
end
def colors_by_name
@colors_by_name ||= self.colors.inject({}) { |sum,c| sum[c.name] = c; sum; }
end
def clear_colors_cache
@colors_by_name = nil
end
def colors_hashes
color_scheme_colors.map do |c|
{name: c.name, hex: c.hex, opacity: c.opacity}
end
end
def previous_version
ColorScheme.where(versioned_id: self.id).where('version < ?', self.version).order('version DESC').first
end
def destroy_versions
ColorScheme.where(versioned_id: self.id).destroy_all
end
end

View File

@ -0,0 +1,99 @@
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"
}
end

View File

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

View File

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

View File

@ -0,0 +1,71 @@
class ColorSchemeRevisor
def initialize(color_scheme, params={})
@color_scheme = color_scheme
@params = params
end
def self.revise(color_scheme, params)
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.update_all enabled: false
end
@color_scheme.name = @params[:name]
@color_scheme.enabled = @params[:enabled]
new_version = false
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]
end
end
if new_version
old_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]
@params[:colors].each do |c|
if existing = @color_scheme.colors_by_name[c[:name]]
existing.update_attributes(c)
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
end
end

View File

@ -1445,6 +1445,21 @@ en:
delete: "Delete"
delete_confirm: "Delete this customization?"
about: "Site Customization allow you to modify stylesheets and headers on the site. Choose or add one to start editing."
color: "Color"
opacity: "Opacity"
copy: "Copy"
css_html:
title: "CSS, HTML"
long_title: "CSS and HTML Customizations"
colors:
title: "Colors"
long_title: "Color Schemes"
about: "Color schemes allow you to modify the colors used on the site without writing CSS. Choose or add one to start."
new_name: "New Color Scheme"
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!"
email:
title: "Email"

View File

@ -1491,3 +1491,6 @@ en:
message_to_blank: "message.to is blank"
text_part_body_blank: "text_part.body is blank"
body_blank: "body is blank"
color_schemes:
base_theme_name: "Base"

View File

@ -84,7 +84,9 @@ Discourse::Application.routes.draw do
resources :screened_urls, only: [:index]
end
get "customize" => "site_customizations#index", constraints: AdminConstraint.new
get "customize" => "color_schemes#index", constraints: AdminConstraint.new
get "customize/css_html" => "site_customizations#index", constraints: AdminConstraint.new
get "customize/colors" => "color_schemes#index", constraints: AdminConstraint.new
get "flags" => "flags#index"
get "flags/:filter" => "flags#index"
post "flags/agree/:id" => "flags#agree"
@ -93,6 +95,7 @@ Discourse::Application.routes.draw do
resources :site_customizations, constraints: AdminConstraint.new
resources :site_contents, constraints: AdminConstraint.new
resources :site_content_types, constraints: AdminConstraint.new
resources :color_schemes, constraints: AdminConstraint.new
get "version_check" => "versions#show"

View File

@ -0,0 +1,14 @@
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,13 @@
class CreateColorSchemes < ActiveRecord::Migration
def change
create_table :color_schemes do |t|
t.string :name, null: false
t.boolean :enabled, null: false, default: false
t.integer :versioned_id
t.integer :version, null: false, default: 1
t.timestamps
end
end
end

View File

@ -0,0 +1,14 @@
class CreateColorSchemeColors < ActiveRecord::Migration
def change
create_table :color_scheme_colors do |t|
t.string :name, null: false
t.string :hex, null: false
t.integer :opacity, null: false, default: 100
t.integer :color_scheme_id, null: false
t.timestamps
end
add_index :color_scheme_colors, [:color_scheme_id]
end
end

View File

@ -0,0 +1,72 @@
require 'spec_helper'
describe Admin::ColorSchemesController do
it "is a subclass of AdminController" do
(described_class < Admin::AdminController).should be_true
end
context "while logged in as an admin" do
let!(:user) { log_in(:admin) }
let(:valid_params) { { color_scheme: {
name: 'Such Design',
enabled: true,
colors: [
{name: '$primary_background_color', hex: 'FFBB00', opacity: '100'},
{name: '$secondary_background_color', hex: '888888', opacity: '70'}
]
}
} }
describe "index" do
it "returns success" do
xhr :get, :index
response.should be_success
end
it "returns JSON" do
Fabricate(:color_scheme)
xhr :get, :index
::JSON.parse(response.body).should be_present
end
end
describe "create" do
it "returns success" do
xhr :post, :create, valid_params
response.should be_success
end
it "returns JSON" do
xhr :post, :create, valid_params
::JSON.parse(response.body)['id'].should be_present
end
end
describe "update" do
let(:existing) { Fabricate(:color_scheme) }
it "returns success" do
ColorSchemeRevisor.expects(:revise).returns(existing)
xhr :put, :update, valid_params.merge(id: existing.id)
response.should be_success
end
it "returns JSON" do
ColorSchemeRevisor.expects(:revise).returns(existing)
xhr :put, :update, valid_params.merge(id: existing.id)
::JSON.parse(response.body)['id'].should be_present
end
end
describe "destroy" do
let!(:existing) { Fabricate(:color_scheme) }
it "returns success" do
expect {
xhr :delete, :destroy, id: existing.id
}.to change { ColorScheme.count }.by(-1)
response.should be_success
end
end
end
end

View File

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

View File

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

View File

@ -0,0 +1,43 @@
require 'spec_helper'
describe ColorScheme do
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'}
]}
describe "new" do
it "can take colors" do
c = described_class.new(valid_params)
c.colors.should have(valid_colors.size).colors
c.colors.first.should be_a(ColorSchemeColor)
expect {
c.save.should == true
}.to change { ColorSchemeColor.count }.by(valid_colors.size)
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 the base color scheme when there is no enabled record" do
described_class.enabled.id.should == 1
end
it "returns the enabled color scheme" do
c = described_class.create(valid_params.merge(enabled: true))
described_class.enabled.id.should == c.id
end
end
end

View File

@ -0,0 +1,116 @@
require 'spec_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 } }
describe "revise" do
it "does nothing if there are no changes" do
expect {
described_class.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"))
color_scheme.reload.name.should == "Changed Name"
end
it "can enable and disable" do
described_class.revise(color_scheme, valid_params.merge(enabled: true))
color_scheme.reload.should be_enabled
described_class.revise(color_scheme, valid_params.merge(enabled: false))
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}
]))
color_scheme.reload
color_scheme.colors.size.should == 1
color_scheme.colors.first.hex.should == 'BEEF99'
color_scheme.colors.first.opacity.should == 99
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))
prev_enabled.reload.enabled.should == false
color_scheme.reload.enabled.should == true
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, old_opacity = color.hex, color.opacity
expect {
described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99', opacity: 99}
]))
}.to change { color_scheme.reload.version }.by(1)
old_version = ColorScheme.where(versioned_id: color_scheme.id, version: color_scheme.version - 1).first
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}
]))
}.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 {
described_class.revert(color_scheme).should == 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', opacity: 99} }
before do
@prev_hex, @prev_opacity = color.hex, color.opacity
described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ]))
end
it "reverts the colors to the previous version" do
color_scheme.colors_by_name[new_color_params[:name]].hex.should == new_color_params[:hex]
expect {
described_class.revert(color_scheme)
}.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
expect {
described_class.revert(color_scheme)
}.to change { ColorScheme.count }.by(-1)
color_scheme.reload.previous_version.should be_nil
end
end
end
end