FEATURE: support uploads for themes
This allows themes to bundle various assets
This commit is contained in:
parent
f709899a1d
commit
bc0b9af576
|
@ -1,6 +1,9 @@
|
||||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||||
import { url } from 'discourse/lib/computed';
|
import { url } from 'discourse/lib/computed';
|
||||||
import { popupAjaxError } from 'discourse/lib/ajax-error';
|
import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||||
|
import showModal from 'discourse/lib/show-modal';
|
||||||
|
|
||||||
|
const THEME_UPLOAD_VAR = 2;
|
||||||
|
|
||||||
export default Ember.Controller.extend({
|
export default Ember.Controller.extend({
|
||||||
|
|
||||||
|
@ -96,6 +99,16 @@ export default Ember.Controller.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addUploadModal() {
|
||||||
|
showModal('admin-add-upload', {admin: true, name: ''});
|
||||||
|
},
|
||||||
|
|
||||||
|
addUpload(info) {
|
||||||
|
let model = this.get("model");
|
||||||
|
model.setField('common', info.name, '', info.upload_id, THEME_UPLOAD_VAR);
|
||||||
|
model.saveChanges('theme_fields').catch(e => popupAjaxError(e));
|
||||||
|
},
|
||||||
|
|
||||||
cancelChangeScheme() {
|
cancelChangeScheme() {
|
||||||
this.set("colorSchemeId", this.get("model.color_scheme_id"));
|
this.set("colorSchemeId", this.get("model.color_scheme_id"));
|
||||||
},
|
},
|
||||||
|
@ -154,6 +167,10 @@ export default Ember.Controller.extend({
|
||||||
this.get("model").addChildTheme(theme);
|
this.get("model").addChildTheme(theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeUpload(upload) {
|
||||||
|
this.get("model").removeField(upload);
|
||||||
|
},
|
||||||
|
|
||||||
removeChildTheme(theme) {
|
removeChildTheme(theme) {
|
||||||
this.get("model").removeChildTheme(theme);
|
this.get("model").removeChildTheme(theme);
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import ModalFunctionality from 'discourse/mixins/modal-functionality';
|
||||||
|
import { ajax } from 'discourse/lib/ajax';
|
||||||
|
// import computed from 'ember-addons/ember-computed-decorators';
|
||||||
|
import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||||
|
|
||||||
|
export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
|
adminCustomizeThemesShow: Ember.inject.controller(),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
updateName() {
|
||||||
|
let name = this.get('name');
|
||||||
|
if (Em.isEmpty(name)) {
|
||||||
|
name = $('#file-input')[0].files[0].name;
|
||||||
|
this.set('name', name.split(".")[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upload() {
|
||||||
|
|
||||||
|
let options = {
|
||||||
|
type: 'POST'
|
||||||
|
};
|
||||||
|
|
||||||
|
options.processData = false;
|
||||||
|
options.contentType = false;
|
||||||
|
options.data = new FormData();
|
||||||
|
let file = $('#file-input')[0].files[0];
|
||||||
|
options.data.append('file', file);
|
||||||
|
|
||||||
|
ajax('/admin/themes/upload_asset', options).then(result=>{
|
||||||
|
let upload = {
|
||||||
|
upload_id: result.upload_id,
|
||||||
|
name: this.get('name'),
|
||||||
|
original_filename: file.name
|
||||||
|
};
|
||||||
|
this.get('adminCustomizeThemesShow').send('addUpload', upload);
|
||||||
|
this.send('closeModal');
|
||||||
|
}).catch(e => {
|
||||||
|
popupAjaxError(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,6 +1,8 @@
|
||||||
import RestModel from 'discourse/models/rest';
|
import RestModel from 'discourse/models/rest';
|
||||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||||
|
|
||||||
|
const THEME_UPLOAD_VAR = 2;
|
||||||
|
|
||||||
const Theme = RestModel.extend({
|
const Theme = RestModel.extend({
|
||||||
|
|
||||||
@computed('theme_fields')
|
@computed('theme_fields')
|
||||||
|
@ -14,12 +16,26 @@ const Theme = RestModel.extend({
|
||||||
let hash = {};
|
let hash = {};
|
||||||
if (fields) {
|
if (fields) {
|
||||||
fields.forEach(field=>{
|
fields.forEach(field=>{
|
||||||
hash[field.target + " " + field.name] = field;
|
if (!field.type_id || field.type_id < THEME_UPLOAD_VAR) {
|
||||||
|
hash[this.getKey(field)] = field;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed('theme_fields', 'theme_fields.@each')
|
||||||
|
uploads(fields) {
|
||||||
|
if (!fields) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fields.filter((f)=> f.target === 'common' && f.type_id === THEME_UPLOAD_VAR);
|
||||||
|
},
|
||||||
|
|
||||||
|
getKey(field){
|
||||||
|
return field.target + " " + field.name;
|
||||||
|
},
|
||||||
|
|
||||||
hasEdited(target, name){
|
hasEdited(target, name){
|
||||||
if (name) {
|
if (name) {
|
||||||
return !Em.isEmpty(this.getField(target, name));
|
return !Em.isEmpty(this.getField(target, name));
|
||||||
|
@ -31,30 +47,56 @@ const Theme = RestModel.extend({
|
||||||
|
|
||||||
getError(target, name) {
|
getError(target, name) {
|
||||||
let themeFields = this.get("themeFields");
|
let themeFields = this.get("themeFields");
|
||||||
let key = target + " " + name;
|
let key = this.getKey({target,name});
|
||||||
let field = themeFields[key];
|
let field = themeFields[key];
|
||||||
return field ? field.error : "";
|
return field ? field.error : "";
|
||||||
},
|
},
|
||||||
|
|
||||||
getField(target, name) {
|
getField(target, name) {
|
||||||
let themeFields = this.get("themeFields");
|
let themeFields = this.get("themeFields");
|
||||||
let key = target + " " + name;
|
let key = this.getKey({target, name})
|
||||||
let field = themeFields[key];
|
let field = themeFields[key];
|
||||||
return field ? field.value : "";
|
return field ? field.value : "";
|
||||||
},
|
},
|
||||||
|
|
||||||
setField(target, name, value) {
|
removeField(field) {
|
||||||
this.set("changed", true);
|
this.set("changed", true);
|
||||||
|
|
||||||
|
field.upload_id = null;
|
||||||
|
field.value = null;
|
||||||
|
|
||||||
|
return this.saveChanges("theme_fields");
|
||||||
|
},
|
||||||
|
|
||||||
|
setField(target, name, value, upload_id, type_id) {
|
||||||
|
this.set("changed", true);
|
||||||
let themeFields = this.get("themeFields");
|
let themeFields = this.get("themeFields");
|
||||||
let key = target + " " + name;
|
let field = {name, target, value, upload_id, type_id};
|
||||||
let field = themeFields[key];
|
|
||||||
if (!field) {
|
// slow path for uploads and so on
|
||||||
field = {name, target, value};
|
if (type_id && type_id > 1) {
|
||||||
|
let fields = this.get("theme_fields");
|
||||||
|
let existing = fields.find((f) =>
|
||||||
|
f.target === target &&
|
||||||
|
f.name === name &&
|
||||||
|
f.type_id === type_id);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
existing.upload_id = upload_id;
|
||||||
|
} else {
|
||||||
|
fields.push(field);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fast path
|
||||||
|
let key = this.getKey({target,name});
|
||||||
|
let existingField = themeFields[key];
|
||||||
|
if (!existingField) {
|
||||||
this.theme_fields.push(field);
|
this.theme_fields.push(field);
|
||||||
themeFields[key] = field;
|
themeFields[key] = field;
|
||||||
} else {
|
} else {
|
||||||
field.value = value;
|
existingField.value = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,6 @@
|
||||||
|
|
||||||
<h3>{{i18n "admin.customize.theme.css_html"}}</h3>
|
<h3>{{i18n "admin.customize.theme.css_html"}}</h3>
|
||||||
{{#if hasEditedFields}}
|
{{#if hasEditedFields}}
|
||||||
|
|
||||||
<p>{{i18n "admin.customize.theme.custom_sections"}}</p>
|
<p>{{i18n "admin.customize.theme.custom_sections"}}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{{#each editedDescriptions as |desc|}}
|
{{#each editedDescriptions as |desc|}}
|
||||||
|
@ -87,6 +86,21 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>{{i18n "admin.customize.theme.uploads"}}</h3>
|
||||||
|
{{#if model.uploads}}
|
||||||
|
<ul class='removable-list'>
|
||||||
|
{{#each model.uploads as |upload|}}
|
||||||
|
<li><span class='first'>${{upload.name}}: <a href={{upload.url}} target='_blank'>{{upload.filename}}</a></span>{{d-button action="removeUpload" actionParam=upload class="second btn-small cancel-edit" icon="times"}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p>{{i18n "admin.customize.theme.no_uploads"}}</p>
|
||||||
|
{{/if}}
|
||||||
|
<p>
|
||||||
|
{{#d-button action="addUploadModal" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
|
||||||
|
</p>
|
||||||
|
|
||||||
{{#if availableChildThemes}}
|
{{#if availableChildThemes}}
|
||||||
<h3>{{i18n "admin.customize.theme.theme_components"}}</h3>
|
<h3>{{i18n "admin.customize.theme.theme_components"}}</h3>
|
||||||
{{#unless model.childThemes.length}}
|
{{#unless model.childThemes.length}}
|
||||||
|
@ -97,9 +111,9 @@
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul>
|
<ul class='removable-list'>
|
||||||
{{#each model.childThemes as |child|}}
|
{{#each model.childThemes as |child|}}
|
||||||
<li>{{#link-to 'adminCustomizeThemes.show' child replace=true}}{{child.name}}{{/link-to}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit" icon="times"}}</li>
|
<li>{{#link-to 'adminCustomizeThemes.show' child replace=true class='first'}}{{child.name}}{{/link-to}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit second" icon="times"}}</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{{#d-modal-body class='upload-selector' title="admin.customize.theme.add_upload"}}
|
||||||
|
<div class="inputs">
|
||||||
|
<input id="name" placeholder={{i18n 'admin.customize.theme.upload_name'}} value={{name}}><br>
|
||||||
|
<input onchange={{action "updateName"}} type="file" id="file-input" accept='*'><br>
|
||||||
|
<span class="description">{{i18n 'admin.customize.theme.upload_file_tip'}}</span>
|
||||||
|
</div>
|
||||||
|
{{/d-modal-body}}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{{d-button action="upload" disabled=loading class='btn btn-primary' icon='upload' label='admin.customize.theme.upload'}}
|
||||||
|
<a href {{action "closeModal"}}>{{i18n 'cancel'}}</a>
|
||||||
|
</div>
|
|
@ -187,5 +187,21 @@
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.removable-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
li {
|
||||||
|
display: table-row;
|
||||||
|
.first {
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.first, .second {
|
||||||
|
display: table-cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,22 @@ class Admin::ThemesController < Admin::AdminController
|
||||||
redirect_to path("/"), flash: {preview_theme_key: @theme.key}
|
redirect_to path("/"), flash: {preview_theme_key: @theme.key}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def upload_asset
|
||||||
|
path = params[:file].path
|
||||||
|
File.open(path) do |file|
|
||||||
|
upload = Upload.create_for(current_user.id,
|
||||||
|
file,
|
||||||
|
params[:original_filename] || File.basename(path),
|
||||||
|
File.size(path),
|
||||||
|
for_theme: true)
|
||||||
|
if upload.errors.count > 0
|
||||||
|
render json: upload.errors, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: {upload_id: upload.id}, status: :created
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
|
|
||||||
@theme = nil
|
@theme = nil
|
||||||
|
@ -184,7 +200,7 @@ class Admin::ThemesController < Admin::AdminController
|
||||||
:color_scheme_id,
|
:color_scheme_id,
|
||||||
:default,
|
:default,
|
||||||
:user_selectable,
|
:user_selectable,
|
||||||
theme_fields: [:name, :target, :value],
|
theme_fields: [:name, :target, :value, :upload_id, :type_id],
|
||||||
child_theme_ids: [])
|
child_theme_ids: [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -194,7 +210,13 @@ class Admin::ThemesController < Admin::AdminController
|
||||||
return unless fields = theme_params[:theme_fields]
|
return unless fields = theme_params[:theme_fields]
|
||||||
|
|
||||||
fields.each do |field|
|
fields.each do |field|
|
||||||
@theme.set_field(target: field[:target], name: field[:name], value: field[:value], type_id: field[:type_id])
|
@theme.set_field(
|
||||||
|
target: field[:target],
|
||||||
|
name: field[:name],
|
||||||
|
value: field[:value],
|
||||||
|
type_id: field[:type_id],
|
||||||
|
upload_id: field[:upload_id]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,34 @@ class RemoteTheme < ActiveRecord::Base
|
||||||
importer.import!
|
importer.import!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
theme_info = JSON.parse(importer["about.json"])
|
||||||
|
|
||||||
|
theme_info["assets"]&.each do |name, relative_path|
|
||||||
|
if path = importer.real_path(relative_path)
|
||||||
|
upload = Upload.create_for(theme.user_id, File.open(path), File.basename(relative_path), File.size(path), for_theme: true)
|
||||||
|
theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
theme_info["fields"]&.each do |name, info|
|
||||||
|
unless Hash === info
|
||||||
|
info = {
|
||||||
|
"target" => :common,
|
||||||
|
"type" => :theme_var,
|
||||||
|
"value" => info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if info["type"] == "color"
|
||||||
|
info["type"] = :theme_color_var
|
||||||
|
end
|
||||||
|
|
||||||
|
theme.set_field(target: info["target"] || :common,
|
||||||
|
name: name,
|
||||||
|
value: info["value"],
|
||||||
|
type: info["type"] || :theme_var)
|
||||||
|
end
|
||||||
|
|
||||||
Theme.targets.keys.each do |target|
|
Theme.targets.keys.each do |target|
|
||||||
ALLOWED_FIELDS.each do |field|
|
ALLOWED_FIELDS.each do |field|
|
||||||
lookup =
|
lookup =
|
||||||
|
@ -62,7 +90,6 @@ class RemoteTheme < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
theme_info = JSON.parse(importer["about.json"])
|
|
||||||
self.license_url ||= theme_info["license_url"]
|
self.license_url ||= theme_info["license_url"]
|
||||||
self.about_url ||= theme_info["about_url"]
|
self.about_url ||= theme_info["about_url"]
|
||||||
self.remote_updated_at = Time.zone.now
|
self.remote_updated_at = Time.zone.now
|
||||||
|
|
|
@ -252,19 +252,16 @@ class Theme < ActiveRecord::Base
|
||||||
type_id ||= type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name)
|
type_id ||= type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name)
|
||||||
raise "Unknown type #{type} passed to set field" unless type_id
|
raise "Unknown type #{type} passed to set field" unless type_id
|
||||||
|
|
||||||
if upload_id && !value
|
value ||= ""
|
||||||
value = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
raise "Missing value for theme field" unless value
|
|
||||||
|
|
||||||
field = theme_fields.find{|f| f.name==name && f.target_id == target_id && f.type_id == type_id}
|
field = theme_fields.find{|f| f.name==name && f.target_id == target_id && f.type_id == type_id}
|
||||||
if field
|
if field
|
||||||
if value.blank?
|
if value.blank? && !upload_id
|
||||||
theme_fields.delete field.destroy
|
theme_fields.delete field.destroy
|
||||||
else
|
else
|
||||||
if field.value != value
|
if field.value != value || field.upload_id != upload_id
|
||||||
field.value = value
|
field.value = value
|
||||||
|
field.upload_id = upload_id
|
||||||
changed_fields << field
|
changed_fields << field
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -112,7 +112,9 @@ COMPILED
|
||||||
begin
|
begin
|
||||||
Stylesheet::Compiler.compile("@import \"theme_variables\"; @import \"theme_field\";",
|
Stylesheet::Compiler.compile("@import \"theme_variables\"; @import \"theme_field\";",
|
||||||
"theme.scss",
|
"theme.scss",
|
||||||
theme_field: self.value.dup)
|
theme_field: self.value.dup,
|
||||||
|
theme: self.theme
|
||||||
|
)
|
||||||
self.error = nil unless error.nil?
|
self.error = nil unless error.nil?
|
||||||
rescue SassC::SyntaxError => e
|
rescue SassC::SyntaxError => e
|
||||||
self.error = e.message
|
self.error = e.message
|
||||||
|
|
|
@ -15,6 +15,7 @@ class Upload < ActiveRecord::Base
|
||||||
has_many :optimized_images, dependent: :destroy
|
has_many :optimized_images, dependent: :destroy
|
||||||
|
|
||||||
attr_accessor :is_attachment_for_group_message
|
attr_accessor :is_attachment_for_group_message
|
||||||
|
attr_accessor :for_theme
|
||||||
|
|
||||||
validates_presence_of :filesize
|
validates_presence_of :filesize
|
||||||
validates_presence_of :original_filename
|
validates_presence_of :original_filename
|
||||||
|
@ -38,7 +39,7 @@ class Upload < ActiveRecord::Base
|
||||||
crop: crop
|
crop: crop
|
||||||
}
|
}
|
||||||
|
|
||||||
if thumbnail = OptimizedImage.create_for(self, width, height, opts)
|
if _thumbnail = OptimizedImage.create_for(self, width, height, opts)
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
save(validate: false)
|
save(validate: false)
|
||||||
|
@ -99,6 +100,7 @@ class Upload < ActiveRecord::Base
|
||||||
# - origin (url)
|
# - origin (url)
|
||||||
# - image_type ("avatar", "profile_background", "card_background", "custom_emoji")
|
# - image_type ("avatar", "profile_background", "card_background", "custom_emoji")
|
||||||
# - is_attachment_for_group_message (boolean)
|
# - is_attachment_for_group_message (boolean)
|
||||||
|
# - for_theme (boolean)
|
||||||
def self.create_for(user_id, file, filename, filesize, options = {})
|
def self.create_for(user_id, file, filename, filesize, options = {})
|
||||||
upload = Upload.new
|
upload = Upload.new
|
||||||
|
|
||||||
|
@ -196,6 +198,10 @@ class Upload < ActiveRecord::Base
|
||||||
upload.is_attachment_for_group_message = true
|
upload.is_attachment_for_group_message = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if options[:for_theme]
|
||||||
|
upload.for_theme = true
|
||||||
|
end
|
||||||
|
|
||||||
if is_dimensionless_image?(filename, upload.width, upload.height)
|
if is_dimensionless_image?(filename, upload.width, upload.height)
|
||||||
upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
|
upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
|
||||||
return upload
|
return upload
|
||||||
|
|
|
@ -1,5 +1,25 @@
|
||||||
class ThemeFieldSerializer < ApplicationSerializer
|
class ThemeFieldSerializer < ApplicationSerializer
|
||||||
attributes :name, :target, :value, :error, :type_id
|
attributes :name, :target, :value, :error, :type_id, :upload_id, :url, :filename
|
||||||
|
|
||||||
|
def include_url?
|
||||||
|
object.upload
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_upload_id?
|
||||||
|
object.upload
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_filename?
|
||||||
|
object.upload
|
||||||
|
end
|
||||||
|
|
||||||
|
def url
|
||||||
|
object.upload&.url
|
||||||
|
end
|
||||||
|
|
||||||
|
def filename
|
||||||
|
object.upload&.original_filename
|
||||||
|
end
|
||||||
|
|
||||||
def target
|
def target
|
||||||
case object.target_id
|
case object.target_id
|
||||||
|
|
|
@ -2841,10 +2841,16 @@ en:
|
||||||
color_scheme_select: "Select colors to be used by theme"
|
color_scheme_select: "Select colors to be used by theme"
|
||||||
custom_sections: "Custom sections:"
|
custom_sections: "Custom sections:"
|
||||||
theme_components: "Theme Components"
|
theme_components: "Theme Components"
|
||||||
|
uploads: "Uploads"
|
||||||
|
no_uploads: "You can upload assets associated with your theme such as fonts and images"
|
||||||
|
add_upload: "Add Upload"
|
||||||
|
upload_file_tip: "Choose an asset to upload (png, woff2, etc...)"
|
||||||
|
upload: "Upload"
|
||||||
child_themes_check: "Theme includes other child themes"
|
child_themes_check: "Theme includes other child themes"
|
||||||
css_html: "Custom CSS/HTML"
|
css_html: "Custom CSS/HTML"
|
||||||
edit_css_html: "Edit CSS/HTML"
|
edit_css_html: "Edit CSS/HTML"
|
||||||
edit_css_html_help: "You have not edited any CSS or HTML"
|
edit_css_html_help: "You have not edited any CSS or HTML"
|
||||||
|
upload_name: "Name"
|
||||||
import_web_tip: "Repository containing theme"
|
import_web_tip: "Repository containing theme"
|
||||||
import_file_tip: ".dcstyle.json file containing theme"
|
import_file_tip: ".dcstyle.json file containing theme"
|
||||||
about_theme: "About Theme"
|
about_theme: "About Theme"
|
||||||
|
|
|
@ -1255,6 +1255,7 @@ en:
|
||||||
max_image_size_kb: "The maximum image upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
|
max_image_size_kb: "The maximum image upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
|
||||||
max_attachment_size_kb: "The maximum attachment files upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
|
max_attachment_size_kb: "The maximum attachment files upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
|
||||||
authorized_extensions: "A list of file extensions allowed for upload (use '*' to enable all file types)"
|
authorized_extensions: "A list of file extensions allowed for upload (use '*' to enable all file types)"
|
||||||
|
theme_authorized_extensions: "A list of file extensions allowed for theme uploads (use '*' to enable all file types)"
|
||||||
max_similar_results: "How many similar topics to show above the editor when composing a new topic. Comparison is based on title and body."
|
max_similar_results: "How many similar topics to show above the editor when composing a new topic. Comparison is based on title and body."
|
||||||
|
|
||||||
max_image_megapixels: "Maximum number of megapixels allowed for an image."
|
max_image_megapixels: "Maximum number of megapixels allowed for an image."
|
||||||
|
|
|
@ -189,6 +189,7 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
resources :themes, constraints: AdminConstraint.new
|
resources :themes, constraints: AdminConstraint.new
|
||||||
post "themes/import" => "themes#import"
|
post "themes/import" => "themes#import"
|
||||||
|
post "themes/upload_asset" => "themes#upload_asset"
|
||||||
get "themes/:id/preview" => "themes#preview"
|
get "themes/:id/preview" => "themes#preview"
|
||||||
|
|
||||||
scope "/customize", constraints: AdminConstraint.new do
|
scope "/customize", constraints: AdminConstraint.new do
|
||||||
|
|
|
@ -713,6 +713,9 @@ files:
|
||||||
default: 40
|
default: 40
|
||||||
min: 5
|
min: 5
|
||||||
max: 150
|
max: 150
|
||||||
|
theme_authorized_extensions:
|
||||||
|
default: 'jpg|jpeg|png|woff|woff2|svg|eot|ttf|otf'
|
||||||
|
type: list
|
||||||
authorized_extensions:
|
authorized_extensions:
|
||||||
client: true
|
client: true
|
||||||
default: 'jpg|jpeg|png|gif'
|
default: 'jpg|jpeg|png|gif'
|
||||||
|
|
|
@ -35,15 +35,24 @@ class GitImporter
|
||||||
FileUtils.rm_rf(@temp_folder)
|
FileUtils.rm_rf(@temp_folder)
|
||||||
end
|
end
|
||||||
|
|
||||||
def [](value)
|
def real_path(relative)
|
||||||
fullpath = "#{@temp_folder}/#{value}"
|
fullpath = "#{@temp_folder}/#{relative}"
|
||||||
return nil unless File.exist?(fullpath)
|
return nil unless File.exist?(fullpath)
|
||||||
|
|
||||||
# careful to handle symlinks here, don't want to expose random data
|
# careful to handle symlinks here, don't want to expose random data
|
||||||
fullpath = Pathname.new(fullpath).realpath.to_s
|
fullpath = Pathname.new(fullpath).realpath.to_s
|
||||||
|
|
||||||
if fullpath && fullpath.start_with?(@temp_folder)
|
if fullpath && fullpath.start_with?(@temp_folder)
|
||||||
File.read(fullpath)
|
fullpath
|
||||||
|
else
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def [](value)
|
||||||
|
fullpath = real_path(value)
|
||||||
|
return nil unless fullpath
|
||||||
|
File.read(fullpath)
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -40,6 +40,7 @@ module Stylesheet
|
||||||
source_map_file: source_map_file,
|
source_map_file: source_map_file,
|
||||||
source_map_contents: true,
|
source_map_contents: true,
|
||||||
theme_id: options[:theme_id],
|
theme_id: options[:theme_id],
|
||||||
|
theme: options[:theme],
|
||||||
theme_field: options[:theme_field],
|
theme_field: options[:theme_field],
|
||||||
load_paths: [ASSET_ROOT])
|
load_paths: [ASSET_ROOT])
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,9 @@ module Stylesheet
|
||||||
colors.each do |n, hex|
|
colors.each do |n, hex|
|
||||||
contents << "$#{n}: ##{hex} !default;\n"
|
contents << "$#{n}: ##{hex} !default;\n"
|
||||||
end
|
end
|
||||||
theme&.theme_fields&.where(type_id: ThemeField.theme_var_type_ids)&.each do |field|
|
theme&.theme_fields&.each do |field|
|
||||||
|
next unless ThemeField.theme_var_type_ids.include?(field.type_id)
|
||||||
|
|
||||||
if field.type_id == ThemeField.types[:theme_upload_var]
|
if field.type_id == ThemeField.types[:theme_upload_var]
|
||||||
if upload = field.upload
|
if upload = field.upload
|
||||||
url = upload_cdn_path(upload.url)
|
url = upload_cdn_path(upload.url)
|
||||||
|
@ -84,8 +86,13 @@ module Stylesheet
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(options)
|
def initialize(options)
|
||||||
|
@theme = options[:theme]
|
||||||
@theme_id = options[:theme_id]
|
@theme_id = options[:theme_id]
|
||||||
@theme_field = options[:theme_field]
|
@theme_field = options[:theme_field]
|
||||||
|
if @theme && !@theme_id
|
||||||
|
# make up an id so other stuff does not bail out
|
||||||
|
@theme_id = @theme.id || -1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def import_files(files)
|
def import_files(files)
|
||||||
|
|
|
@ -24,11 +24,11 @@ class Validators::UploadValidator < ActiveModel::Validator
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_authorized?(upload, extension)
|
def is_authorized?(upload, extension)
|
||||||
authorized_extensions(upload, extension, authorized_uploads)
|
authorized_extensions(upload, extension, authorized_uploads(upload))
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_image_extension(upload, extension)
|
def authorized_image_extension(upload, extension)
|
||||||
authorized_extensions(upload, extension, authorized_images)
|
authorized_extensions(upload, extension, authorized_images(upload))
|
||||||
end
|
end
|
||||||
|
|
||||||
def maximum_image_file_size(upload)
|
def maximum_image_file_size(upload)
|
||||||
|
@ -36,7 +36,7 @@ class Validators::UploadValidator < ActiveModel::Validator
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_attachment_extension(upload, extension)
|
def authorized_attachment_extension(upload, extension)
|
||||||
authorized_extensions(upload, extension, authorized_attachments)
|
authorized_extensions(upload, extension, authorized_attachments(upload))
|
||||||
end
|
end
|
||||||
|
|
||||||
def maximum_attachment_file_size(upload)
|
def maximum_attachment_file_size(upload)
|
||||||
|
@ -45,10 +45,12 @@ class Validators::UploadValidator < ActiveModel::Validator
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authorized_uploads
|
def authorized_uploads(upload)
|
||||||
authorized_uploads = Set.new
|
authorized_uploads = Set.new
|
||||||
|
|
||||||
SiteSetting.authorized_extensions
|
extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions
|
||||||
|
|
||||||
|
extensions
|
||||||
.gsub(/[\s\.]+/, "")
|
.gsub(/[\s\.]+/, "")
|
||||||
.downcase
|
.downcase
|
||||||
.split("|")
|
.split("|")
|
||||||
|
@ -57,20 +59,21 @@ class Validators::UploadValidator < ActiveModel::Validator
|
||||||
authorized_uploads
|
authorized_uploads
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_images
|
def authorized_images(upload)
|
||||||
authorized_uploads & FileHelper.images
|
authorized_uploads(upload) & FileHelper.images
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_attachments
|
def authorized_attachments(upload)
|
||||||
authorized_uploads - FileHelper.images
|
authorized_uploads(upload) - FileHelper.images
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorizes_all_extensions?
|
def authorizes_all_extensions?(upload)
|
||||||
SiteSetting.authorized_extensions.include?("*")
|
extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions
|
||||||
|
extensions.include?("*")
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_extensions(upload, extension, extensions)
|
def authorized_extensions(upload, extension, extensions)
|
||||||
return true if authorizes_all_extensions?
|
return true if authorizes_all_extensions?(upload)
|
||||||
|
|
||||||
unless authorized = extensions.include?(extension.downcase)
|
unless authorized = extensions.include?(extension.downcase)
|
||||||
message = I18n.t("upload.unauthorized", authorized_extensions: extensions.to_a.join(", "))
|
message = I18n.t("upload.unauthorized", authorized_extensions: extensions.to_a.join(", "))
|
||||||
|
|
|
@ -11,6 +11,26 @@ describe Admin::ThemesController do
|
||||||
@user = log_in(:admin)
|
@user = log_in(:admin)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context '.upload_asset' do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:upload) do
|
||||||
|
ActionDispatch::Http::UploadedFile.new({
|
||||||
|
filename: 'test.woff2',
|
||||||
|
tempfile: file_from_fixtures("fake.woff2", "woff2")
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can create a theme upload' do
|
||||||
|
xhr :post, :upload_asset, file: upload, original_filename: 'wooof.woff2'
|
||||||
|
expect(response.status).to eq(201)
|
||||||
|
upload = Upload.find_by(original_filename: "wooof.woff2")
|
||||||
|
expect(upload.id).not_to be_nil
|
||||||
|
expect(JSON.parse(response.body)["upload_id"]).to eq(upload.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
context '.import' do
|
context '.import' do
|
||||||
let(:theme_file) do
|
let(:theme_file) do
|
||||||
ActionDispatch::Http::UploadedFile.new({
|
ActionDispatch::Http::UploadedFile.new({
|
||||||
|
@ -93,30 +113,35 @@ describe Admin::ThemesController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates a theme' do
|
it 'updates a theme' do
|
||||||
#focus
|
|
||||||
theme = Theme.new(name: 'my name', user_id: -1)
|
theme = Theme.new(name: 'my name', user_id: -1)
|
||||||
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
|
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
|
||||||
theme.save
|
theme.save
|
||||||
|
|
||||||
child_theme = Theme.create(name: 'my name', user_id: -1)
|
child_theme = Theme.create(name: 'my name', user_id: -1)
|
||||||
|
|
||||||
|
upload = Fabricate(:upload)
|
||||||
|
|
||||||
xhr :put, :update, id: theme.id,
|
xhr :put, :update, id: theme.id,
|
||||||
theme: {
|
theme: {
|
||||||
child_theme_ids: [child_theme.id],
|
child_theme_ids: [child_theme.id],
|
||||||
name: 'my test name',
|
name: 'my test name',
|
||||||
theme_fields: [
|
theme_fields: [
|
||||||
{ name: 'scss', target: 'common', value: '' },
|
{ name: 'scss', target: 'common', value: '' },
|
||||||
{ name: 'scss', target: 'desktop', value: 'body{color: blue;}' }
|
{ name: 'scss', target: 'desktop', value: 'body{color: blue;}' },
|
||||||
|
{ name: 'bob', target: 'common', value: '', type_id: 2, upload_id: upload.id },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
|
|
||||||
json = ::JSON.parse(response.body)
|
json = ::JSON.parse(response.body)
|
||||||
|
|
||||||
fields = json["theme"]["theme_fields"]
|
fields = json["theme"]["theme_fields"].sort{|a,b| a["value"] <=> b["value"]}
|
||||||
|
|
||||||
expect(fields.first["value"]).to eq('body{color: blue;}')
|
expect(fields[0]["value"]).to eq('')
|
||||||
expect(fields.length).to eq(1)
|
expect(fields[0]["upload_id"]).to eq(upload.id)
|
||||||
|
expect(fields[1]["value"]).to eq('body{color: blue;}')
|
||||||
|
|
||||||
|
expect(fields.length).to eq(2)
|
||||||
|
|
||||||
expect(json["theme"]["child_themes"].length).to eq(1)
|
expect(json["theme"]["child_themes"].length).to eq(1)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
not a woff
|
|
@ -9,7 +9,7 @@ describe RemoteTheme do
|
||||||
`cd #{repo_dir} && git init . `
|
`cd #{repo_dir} && git init . `
|
||||||
`cd #{repo_dir} && git config user.email 'someone@cool.com'`
|
`cd #{repo_dir} && git config user.email 'someone@cool.com'`
|
||||||
`cd #{repo_dir} && git config user.name 'The Cool One'`
|
`cd #{repo_dir} && git config user.name 'The Cool One'`
|
||||||
`cd #{repo_dir} && mkdir desktop mobile common`
|
`cd #{repo_dir} && mkdir desktop mobile common assets`
|
||||||
files.each do |name, data|
|
files.each do |name, data|
|
||||||
File.write("#{repo_dir}/#{name}", data)
|
File.write("#{repo_dir}/#{name}", data)
|
||||||
`cd #{repo_dir} && git add #{name}`
|
`cd #{repo_dir} && git add #{name}`
|
||||||
|
@ -26,6 +26,17 @@ describe RemoteTheme do
|
||||||
"name": "awesome theme",
|
"name": "awesome theme",
|
||||||
"about_url": "https://www.site.com/about",
|
"about_url": "https://www.site.com/about",
|
||||||
"license_url": "https://www.site.com/license",
|
"license_url": "https://www.site.com/license",
|
||||||
|
"assets": {
|
||||||
|
"font": "assets/awesome.woff2"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"color": {
|
||||||
|
"target": "desktop",
|
||||||
|
"value": "#FEF",
|
||||||
|
"type": "color"
|
||||||
|
},
|
||||||
|
"name": "sam"
|
||||||
|
},
|
||||||
"color_schemes": {
|
"color_schemes": {
|
||||||
"Amazing": {
|
"Amazing": {
|
||||||
"love": "#{options[:love]}"
|
"love": "#{options[:love]}"
|
||||||
|
@ -35,13 +46,18 @@ describe RemoteTheme do
|
||||||
JSON
|
JSON
|
||||||
end
|
end
|
||||||
|
|
||||||
|
let :scss_data do
|
||||||
|
"@font-face { font-family: magic; src: url($font)}; body {color: $color; content: $name;}"
|
||||||
|
end
|
||||||
|
|
||||||
let :initial_repo do
|
let :initial_repo do
|
||||||
setup_git_repo(
|
setup_git_repo(
|
||||||
"about.json" => about_json,
|
"about.json" => about_json,
|
||||||
"desktop/desktop.scss" => "body {color: red;}",
|
"desktop/desktop.scss" => scss_data,
|
||||||
"common/header.html" => "I AM HEADER",
|
"common/header.html" => "I AM HEADER",
|
||||||
"common/random.html" => "I AM SILLY",
|
"common/random.html" => "I AM SILLY",
|
||||||
"common/embedded.scss" => "EMBED",
|
"common/embedded.scss" => "EMBED",
|
||||||
|
"assets/awesome.woff2" => "FAKE FONT",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -65,14 +81,20 @@ JSON
|
||||||
expect(remote.about_url).to eq("https://www.site.com/about")
|
expect(remote.about_url).to eq("https://www.site.com/about")
|
||||||
expect(remote.license_url).to eq("https://www.site.com/license")
|
expect(remote.license_url).to eq("https://www.site.com/license")
|
||||||
|
|
||||||
expect(@theme.theme_fields.length).to eq(3)
|
expect(@theme.theme_fields.length).to eq(6)
|
||||||
|
|
||||||
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
|
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
|
||||||
|
|
||||||
expect(mapped["0-header"]).to eq("I AM HEADER")
|
expect(mapped["0-header"]).to eq("I AM HEADER")
|
||||||
expect(mapped["1-scss"]).to eq("body {color: red;}")
|
expect(mapped["1-scss"]).to eq(scss_data)
|
||||||
expect(mapped["0-embedded_scss"]).to eq("EMBED")
|
expect(mapped["0-embedded_scss"]).to eq("EMBED")
|
||||||
|
|
||||||
|
expect(mapped["1-color"]).to eq("#FEF")
|
||||||
|
expect(mapped["0-font"]).to eq("")
|
||||||
|
expect(mapped["0-name"]).to eq("sam")
|
||||||
|
|
||||||
|
expect(mapped.length).to eq(6)
|
||||||
|
|
||||||
expect(remote.remote_updated_at).to eq(time)
|
expect(remote.remote_updated_at).to eq(time)
|
||||||
|
|
||||||
scheme = ColorScheme.find_by(theme_id: @theme.id)
|
scheme = ColorScheme.find_by(theme_id: @theme.id)
|
||||||
|
@ -104,7 +126,7 @@ JSON
|
||||||
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
|
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
|
||||||
|
|
||||||
expect(mapped["0-header"]).to eq("I AM UPDATED")
|
expect(mapped["0-header"]).to eq("I AM UPDATED")
|
||||||
expect(mapped["1-scss"]).to eq("body {color: red;}")
|
expect(mapped["1-scss"]).to eq(scss_data)
|
||||||
expect(remote.remote_updated_at).to eq(time)
|
expect(remote.remote_updated_at).to eq(time)
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -182,6 +182,9 @@ HTML
|
||||||
|
|
||||||
expect(Upload.where(id: upload.id)).to be_exist
|
expect(Upload.where(id: upload.id)).to be_exist
|
||||||
|
|
||||||
|
# no error for theme field
|
||||||
|
theme.reload
|
||||||
|
expect(theme.theme_fields.find_by(name: :scss).error).to eq(nil)
|
||||||
|
|
||||||
scss,_map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
|
scss,_map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
|
||||||
expect(scss).to include(upload.url)
|
expect(scss).to include(upload.url)
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Theme from 'admin/models/theme';
|
||||||
|
|
||||||
|
module("model:theme");
|
||||||
|
|
||||||
|
test('can add an upload correctly', function(assert) {
|
||||||
|
let theme = Theme.create();
|
||||||
|
|
||||||
|
assert.equal(theme.get("uploads.length"), 0, "uploads should be an empty array");
|
||||||
|
|
||||||
|
theme.setField('common', 'bob', '', 999, 2);
|
||||||
|
let fields = theme.get("theme_fields");
|
||||||
|
assert.equal(fields.length, 1, 'expecting 1 theme field');
|
||||||
|
assert.equal(fields[0].upload_id, 999, 'expecting upload id to be set');
|
||||||
|
assert.equal(fields[0].type_id, 2, 'expecting type id to be set');
|
||||||
|
|
||||||
|
assert.equal(theme.get("uploads.length"), 1, "expecting an upload");
|
||||||
|
});
|
Loading…
Reference in New Issue