FEATURE: custom emojis
20
app/assets/javascripts/admin/controllers/admin-emojis.js.es6
Normal file
|
@ -0,0 +1,20 @@
|
|||
export default Ember.ArrayController.extend({
|
||||
sortProperties: ["name"],
|
||||
|
||||
actions: {
|
||||
emojiUploaded: function (emoji) {
|
||||
this.pushObject(emoji);
|
||||
},
|
||||
|
||||
destroy: function(emoji) {
|
||||
var self = this;
|
||||
return bootbox.confirm(I18n.t("admin.emoji.delete_confirm", { name: emoji.name }), I18n.t("no_value"), I18n.t("yes_value"), function (destroy) {
|
||||
if (destroy) {
|
||||
return Discourse.ajax("/admin/customize/emojis/" + emoji.name, { type: "DELETE" }).then(function() {
|
||||
self.removeObject(emoji);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
7
app/assets/javascripts/admin/routes/admin-emojis.js.es6
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default Discourse.Route.extend({
|
||||
model: function() {
|
||||
return Discourse.ajax("/admin/customize/emojis.json").then(function(emojis) {
|
||||
return emojis.map(function (emoji) { return Ember.Object.create(emoji); });
|
||||
});
|
||||
}
|
||||
});
|
|
@ -18,8 +18,8 @@ Discourse.Route.buildRoutes(function() {
|
|||
this.resource('adminSiteText', { path: '/site_text' }, function() {
|
||||
this.route('edit', {path: '/:text_type'});
|
||||
});
|
||||
this.resource('adminUserFields', { path: '/user_fields' }, function() {
|
||||
});
|
||||
this.resource('adminUserFields', { path: '/user_fields' });
|
||||
this.resource('adminEmojis', { path: '/emojis' });
|
||||
});
|
||||
this.route('api');
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<li>{{#link-to 'adminCustomize.css_html'}}{{i18n 'admin.customize.css_html.title'}}{{/link-to}}</li>
|
||||
<li>{{#link-to 'adminSiteText'}}{{i18n 'admin.site_text.title'}}{{/link-to}}</li>
|
||||
<li>{{#link-to 'adminUserFields'}}{{i18n 'admin.user_fields.title'}}{{/link-to}}</li>
|
||||
<li>{{#link-to 'adminEmojis'}}{{i18n 'admin.emoji.title'}}{{/link-to}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
30
app/assets/javascripts/admin/templates/emojis.hbs
Normal file
|
@ -0,0 +1,30 @@
|
|||
<div class='emoji'>
|
||||
<h2>{{i18n 'admin.emoji.title'}}</h2>
|
||||
|
||||
<p class="desc">{{i18n 'admin.emoji.help'}}</p>
|
||||
|
||||
<p>{{emoji-uploader done="emojiUploaded"}}</p>
|
||||
|
||||
{{#if controller}}
|
||||
<div class="span8">
|
||||
<table id="custom_emoji">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{i18n "admin.emoji.image"}}</th>
|
||||
<th>{{i18n "admin.emoji.name"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each e in controller}}
|
||||
<tr>
|
||||
<th><img class="emoji" src="{{unbound e.url}}" title="{{unbound e.name}}"></th>
|
||||
<th>:{{e.name}}:</th>
|
||||
<th><button {{action "destroy" e}} class='btn btn-danger no-text pull-right'>{{fa-icon 'trash-o'}} </button></th>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -38,8 +38,7 @@
|
|||
<div class='form-display'><strong>{{f.name}}</strong></div>
|
||||
<div class='form-display'>{{{f.description}}}</div>
|
||||
<div class='form-display'>{{f.fieldName}}</div>
|
||||
<div class='form-display'>
|
||||
</div>
|
||||
<div class='form-display'></div>
|
||||
<div class='form-element controls'>
|
||||
<button {{action "edit"}}class='btn btn-default'>{{fa-icon 'pencil'}} {{i18n 'admin.user_fields.edit'}}</button>
|
||||
<button {{action "destroy"}}class='btn btn-danger'>{{fa-icon 'trash-o'}} {{i18n 'admin.user_fields.delete'}}</button>
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import UploadMixin from 'discourse/mixins/upload';
|
||||
|
||||
export default Em.Component.extend(UploadMixin, {
|
||||
type: "emoji",
|
||||
uploadUrl: "/admin/customize/emojis",
|
||||
|
||||
hasName: Em.computed.notEmpty("name"),
|
||||
addDisabled: Em.computed.not("hasName"),
|
||||
|
||||
data: function() {
|
||||
return Ember.isBlank(this.get("name")) ? {} : { name: this.get("name") };
|
||||
}.property("name"),
|
||||
|
||||
uploadDone: function (data) {
|
||||
this.set("name", null);
|
||||
this.sendAction("done", data.result);
|
||||
}
|
||||
|
||||
});
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
// TODO: Make this a proper ES6 import
|
||||
var ComposerView = require('discourse/views/composer').default;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
|
||||
var _groups = [
|
||||
var groups = [
|
||||
{
|
||||
name: "emoticons",
|
||||
icons: ["smile","smiley","grinning","blush","relaxed","wink","heart_eyes","kissing_heart","kissing_closed_eyes","kissing","kissing_smiling_eyes","stuck_out_tongue_winking_eye","stuck_out_tongue_closed_eyes","stuck_out_tongue","flushed","grin","pensive","relieved","unamused","disappointed","persevere","cry","joy","sob","sleepy","disappointed_relieved","cold_sweat","sweat_smile","sweat","weary","tired_face","fearful","scream","angry","rage","triumph","confounded","laughing","yum","mask","sunglasses","sleeping","dizzy_face","astonished","worried","frowning","anguished","smiling_imp","imp","open_mouth","grimacing","neutral_face","confused","hushed","no_mouth","innocent","smirk","expressionless","man_with_gua_pi_mao","man_with_turban","cop","construction_worker","guardsman","baby","boy","girl","man","woman","older_man","older_woman","person_with_blond_hair","angel","princess","smiley_cat","smile_cat","heart_eyes_cat","kissing_cat","smirk_cat","scream_cat","crying_cat_face","joy_cat","pouting_cat","japanese_ogre","japanese_goblin","see_no_evil","hear_no_evil","speak_no_evil","skull","alien","poop","fire","sparkles","star2","dizzy","boom","anger","sweat_drops","droplet","zzz","dash","ear","eyes","nose","tongue","lips","thumbsup","thumbsdown","ok_hand","punch","fist","v","wave","raised_hand","open_hands","point_up_2","point_down","point_right","point_left","raised_hands","pray","point_up","clap","muscle","walking","runner","dancer","couple","family","two_men_holding_hands","two_women_holding_hands","couplekiss","couple_with_heart","dancers","ok_woman","no_good","information_desk_person","raising_hand","massage","haircut","nail_care","bride_with_veil","person_with_pouting_face","person_frowning","bow","tophat","crown","womans_hat","athletic_shoe","mans_shoe","sandal","high_heel","boot","shirt","necktie","womans_clothes","dress","running_shirt_with_sash","jeans","kimono","bikini","briefcase","handbag","pouch","purse","eyeglasses","ribbon","closed_umbrella","lipstick","yellow_heart","blue_heart","purple_heart","green_heart","heart","broken_heart","heartpulse","heartbeat","two_hearts","sparkling_heart","revolving_hearts","cupid","love_letter","kiss","ring","gem","bust_in_silhouette","busts_in_silhouette","speech_balloon","footprints","thought_balloon"]
|
||||
|
@ -27,29 +26,29 @@ var _groups = [
|
|||
];
|
||||
|
||||
// scrub groups
|
||||
_groups.forEach(function(group){
|
||||
groups.forEach(function(group){
|
||||
group.icons = _.reject(group.icons, function(obj){
|
||||
return !Discourse.Emoji.exists(obj);
|
||||
});
|
||||
});
|
||||
|
||||
// export so others can modify
|
||||
Discourse.Emoji.groups = _groups;
|
||||
Discourse.Emoji.groups = groups;
|
||||
|
||||
var closeSelector = function(){
|
||||
$('.emoji-modal, .emoji-modal-wrapper').remove();
|
||||
$('body, textarea').off('keydown.emoji');
|
||||
};
|
||||
|
||||
var _ungroupedIcons;
|
||||
var ungroupedIcons;
|
||||
|
||||
var toolbar = function(selected){
|
||||
|
||||
if(!_ungroupedIcons){
|
||||
_ungroupedIcons = [];
|
||||
if(!ungroupedIcons){
|
||||
ungroupedIcons = [];
|
||||
var groupedIcons = {};
|
||||
|
||||
_.each(_groups, function(group){
|
||||
_.each(groups, function(group){
|
||||
_.each(group.icons, function(icon){
|
||||
groupedIcons[icon] = true;
|
||||
});
|
||||
|
@ -58,16 +57,16 @@ var toolbar = function(selected){
|
|||
var emojis = Discourse.Emoji.list();
|
||||
_.each(emojis,function(emoji){
|
||||
if(groupedIcons[emoji] !== true){
|
||||
_ungroupedIcons.push(emoji);
|
||||
ungroupedIcons.push(emoji);
|
||||
}
|
||||
});
|
||||
|
||||
if(_ungroupedIcons.length > 0){
|
||||
_groups.push({name: 'ungrouped', icons: _ungroupedIcons});
|
||||
if(ungroupedIcons.length > 0){
|
||||
groups.push({name: 'ungrouped', icons: ungroupedIcons});
|
||||
}
|
||||
}
|
||||
|
||||
return _.map(_groups, function(g, i){
|
||||
return _.map(groups, function(g, i){
|
||||
var row = {src: Discourse.Emoji.urlFor(g.icons[0]), groupId: i};
|
||||
if(i===selected){
|
||||
row.selected = true;
|
||||
|
@ -111,9 +110,10 @@ var bindEvents = function(page,offset){
|
|||
var render = function(page, offset){
|
||||
var rows = [];
|
||||
var row = [];
|
||||
var icons = _groups[page].icons;
|
||||
var icons = groups[page].icons;
|
||||
var max = offset + PER_PAGE;
|
||||
|
||||
for(var i=offset; i<(offset+PER_PAGE); i++){
|
||||
for(var i=offset; i<max; i++){
|
||||
if(!icons[i]){ break; }
|
||||
if(row.length === PER_ROW){
|
||||
rows.push(row);
|
||||
|
@ -127,14 +127,14 @@ var render = function(page, offset){
|
|||
toolbarItems: toolbar(page),
|
||||
rows: rows,
|
||||
prevDisabled: offset === 0,
|
||||
nextDisabled: (offset + PER_PAGE + 1) > icons.length
|
||||
nextDisabled: (max + 1) > icons.length
|
||||
};
|
||||
|
||||
$('body .emoji-modal').remove();
|
||||
var rendered = Ember.TEMPLATES["javascripts/emoji-toolbar.raw"](model);
|
||||
var rendered = Ember.TEMPLATES["emoji-toolbar.raw"](model);
|
||||
$('body').append(rendered);
|
||||
|
||||
bindEvents(page,offset);
|
||||
bindEvents(page, offset);
|
||||
};
|
||||
|
||||
var showSelector = function(){
|
|
@ -3,47 +3,45 @@ Discourse.Emoji = {};
|
|||
// bump up this number to expire all emojis
|
||||
Discourse.Emoji.ImageVersion = "0"
|
||||
|
||||
var _emoji = <%= Emoji.all.map { |e| e["aliases"] }.flatten.inspect %>;
|
||||
var emoji = <%= Emoji.standard.map(&:name).flatten.inspect %>;
|
||||
|
||||
var _extendedEmoji = {};
|
||||
var extendedEmoji = {};
|
||||
Discourse.Dialect.registerEmoji = function(code, url) {
|
||||
_extendedEmoji[code] = url;
|
||||
extendedEmoji[code] = url;
|
||||
};
|
||||
|
||||
Discourse.Emoji.list = function(){
|
||||
var copy = _emoji.slice(0);
|
||||
_.each(_extendedEmoji, function(v,k){
|
||||
copy.push(k);
|
||||
});
|
||||
return copy;
|
||||
var list = emoji.slice(0);
|
||||
_.each(extendedEmoji, function(v,k){ list.push(k); });
|
||||
return list;
|
||||
};
|
||||
|
||||
var _toSearch;
|
||||
var toSearch;
|
||||
|
||||
var search = function(term, options) {
|
||||
var maxResults = (options && options["maxResults"]) || -1;
|
||||
|
||||
_toSearch = _toSearch || _emoji.concat(Object.keys(_extendedEmoji));
|
||||
toSearch = toSearch || emoji.concat(Object.keys(extendedEmoji));
|
||||
|
||||
if(maxResults === 0) { return []; }
|
||||
if (maxResults === 0) { return []; }
|
||||
|
||||
var i, results = [];
|
||||
|
||||
var done = function(){
|
||||
var done = function() {
|
||||
return maxResults > 0 && results.length >= maxResults;
|
||||
}
|
||||
|
||||
for (i=0; i < _toSearch.length; i++) {
|
||||
if (_toSearch[i].indexOf(term) === 0) {
|
||||
results.push(_toSearch[i]);
|
||||
for (i=0; i < toSearch.length; i++) {
|
||||
if (toSearch[i].indexOf(term) === 0) {
|
||||
results.push(toSearch[i]);
|
||||
if(done()) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
if(!done()){
|
||||
for (i=0; i < _toSearch.length; i++) {
|
||||
if (_toSearch[i].indexOf(term) > 0) {
|
||||
results.push(_toSearch[i]);
|
||||
for (i=0; i < toSearch.length; i++) {
|
||||
if (toSearch[i].indexOf(term) > 0) {
|
||||
results.push(toSearch[i]);
|
||||
if(done()) { break; }
|
||||
}
|
||||
}
|
||||
|
@ -54,20 +52,20 @@ var search = function(term, options) {
|
|||
|
||||
Discourse.Emoji.search = search;
|
||||
|
||||
var _emojiHash = {};
|
||||
_emoji.forEach(function(code){
|
||||
_emojiHash[code] = true;
|
||||
var emojiHash = {};
|
||||
emoji.forEach(function(code){
|
||||
emojiHash[code] = true;
|
||||
});
|
||||
|
||||
var urlFor = function(code) {
|
||||
var url, set = Discourse.SiteSettings.emoji_set;
|
||||
|
||||
if(_extendedEmoji.hasOwnProperty(code)) {
|
||||
url = _extendedEmoji[code];
|
||||
if(extendedEmoji.hasOwnProperty(code)) {
|
||||
url = extendedEmoji[code];
|
||||
}
|
||||
|
||||
if(!url && _emojiHash.hasOwnProperty(code)) {
|
||||
url = Discourse.getURL('/plugins/emoji/images/' + set + '/' + code + '.png');
|
||||
if(!url && emojiHash.hasOwnProperty(code)) {
|
||||
url = Discourse.getURL('/images/emoji/' + set + '/' + code + '.png');
|
||||
}
|
||||
|
||||
if(url && url[0] !== 'h' && Discourse.CDN) {
|
||||
|
@ -84,7 +82,7 @@ var urlFor = function(code) {
|
|||
Discourse.Emoji.urlFor = urlFor;
|
||||
|
||||
Discourse.Emoji.exists = function(code){
|
||||
return !!(_extendedEmoji.hasOwnProperty(code) || _emojiHash.hasOwnProperty(code));
|
||||
return !!(extendedEmoji.hasOwnProperty(code) || emojiHash.hasOwnProperty(code));
|
||||
}
|
||||
|
||||
function imageFor(code) {
|
|
@ -297,7 +297,11 @@ Discourse.Utilities = {
|
|||
|
||||
// the error message is provided by the server
|
||||
case 422:
|
||||
bootbox.alert(data.jqXHR.responseJSON.join("\n"));
|
||||
if (data.jqXHR.responseJSON.message) {
|
||||
bootbox.alert(data.jqXHR.responseJSON.message);
|
||||
} else {
|
||||
bootbox.alert(data.jqXHR.responseJSON.join("\n"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,15 +20,17 @@ export default Em.Mixin.create({
|
|||
url: this.get('uploadUrl'),
|
||||
dataType: "json",
|
||||
fileInput: $upload,
|
||||
formData: { image_type: this.get('type') },
|
||||
dropZone: this.$(),
|
||||
pasteZone: this.$()
|
||||
});
|
||||
|
||||
$upload.on('fileuploadsubmit', function (e, data) {
|
||||
var result = Discourse.Utilities.validateUploadedFiles(data.files, true);
|
||||
self.setProperties({ uploadProgress: 0, uploading: result });
|
||||
return result;
|
||||
var isValid = Discourse.Utilities.validateUploadedFiles(data.files, true);
|
||||
var form = { image_type: self.get('type') };
|
||||
if (self.get("data")) { form = $.extend(form, self.get("data")); }
|
||||
data.formData = form;
|
||||
self.setProperties({ uploadProgress: 0, uploading: isValid });
|
||||
return isValid;
|
||||
});
|
||||
|
||||
$upload.on("fileuploadprogressall", function(e, data) {
|
||||
|
@ -40,7 +42,11 @@ export default Em.Mixin.create({
|
|||
if(data.result.url) {
|
||||
self.uploadDone(data);
|
||||
} else {
|
||||
bootbox.alert(I18n.t('post.errors.upload'));
|
||||
if (data.result.message) {
|
||||
bootbox.alert(data.result.message);
|
||||
} else {
|
||||
bootbox.alert(I18n.t('post.errors.upload'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{{text-field name="name" placeholderKey="admin.emoji.name" value=name}}
|
||||
<input type="file" accept=".png,.gif" style="display:none" />
|
||||
<button {{bind-attr disabled="addDisabled"}} {{action "selectFile"}} class='btn btn-primary'>
|
||||
{{fa-icon "plus"}}
|
||||
{{i18n 'admin.emoji.add'}}
|
||||
</button>
|
|
@ -1,10 +1,10 @@
|
|||
<input type="file" accept="image/*" style="display:none" />
|
||||
<div class="uploaded-image-preview" class="input-xxlarge" {{bind-attr style="backgroundStyle"}}>
|
||||
<div class="image-upload-controls">
|
||||
<button {{action "selectFile"}} class="btn pad-left no-text"><i class="fa fa-picture-o"></i></button>
|
||||
{{#if backgroundStyle}}
|
||||
<button {{action "trash"}} class="btn btn-danger pad-left no-text"><i class="fa fa-trash-o"></i></button>
|
||||
{{/if}}
|
||||
<span {{bind-attr class=":btn uploading::hidden"}}>{{i18n 'upload_selector.uploading'}} {{uploadProgress}}%</span>
|
||||
<button {{action "selectFile"}} class="btn pad-left no-text">{{fa-icon "picture-o"}}</button>
|
||||
{{#if backgroundStyle}}
|
||||
<button {{action "trash"}} class="btn btn-danger pad-left no-text">{{fa-icon "trash-o"}}</button>
|
||||
{{/if}}
|
||||
<span {{bind-attr class=":btn uploading::hidden"}}>{{i18n 'upload_selector.uploading'}} {{uploadProgress}}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,14 +12,15 @@
|
|||
// Stuff we need to load first
|
||||
//= require ./discourse/helpers/i18n
|
||||
//= require ./discourse/lib/ember_compat_handlebars
|
||||
//= require ./discourse/helpers/register-unbound
|
||||
//= require ./discourse/lib/computed
|
||||
//= require ./discourse/helpers/register-unbound
|
||||
//= require ./discourse/mixins/scrolling
|
||||
//= require_tree ./discourse/mixins
|
||||
//= require ./discourse/lib/markdown
|
||||
//= require ./discourse/lib/search-for-term
|
||||
//= require ./discourse/views/view
|
||||
//= require ./discourse/views/container
|
||||
//= require ./discourse/lib/user-search
|
||||
//= require ./discourse/lib/autocomplete
|
||||
//= require ./discourse/lib/after-transition
|
||||
//= require ./discourse/lib/debounce
|
||||
//= require ./discourse/models/model
|
||||
//= require ./discourse/models/user_action
|
||||
|
@ -30,6 +31,8 @@
|
|||
//= require ./discourse/controllers/discovery-sortable
|
||||
//= require ./discourse/controllers/object
|
||||
//= require ./discourse/controllers/navigation/default
|
||||
//= require ./discourse/views/view
|
||||
//= require ./discourse/views/container
|
||||
//= require ./discourse/views/modal_body_view
|
||||
//= require ./discourse/views/flag
|
||||
//= require ./discourse/views/combo-box
|
||||
|
@ -38,6 +41,7 @@
|
|||
//= require ./discourse/views/notifications-button
|
||||
//= require ./discourse/views/topic-notifications-button
|
||||
//= require ./discourse/views/pagedown-preview
|
||||
//= require ./discourse/views/composer
|
||||
//= require ./discourse/routes/discourse_route
|
||||
//= require ./discourse/routes/build-topic-route
|
||||
//= require ./discourse/routes/restricted-user
|
||||
|
@ -52,8 +56,9 @@
|
|||
//= require ./discourse/helpers/cold-age-class
|
||||
//= require ./discourse/helpers/loading-spinner
|
||||
//= require ./discourse/helpers/category-link
|
||||
|
||||
//= require ./discourse/dialects/dialect
|
||||
//= require ./discourse/lib/emoji/emoji
|
||||
|
||||
//= require_tree ./discourse/dialects
|
||||
//= require_tree ./discourse/controllers
|
||||
//= require_tree ./discourse/lib
|
||||
|
|
36
app/controllers/admin/emojis_controller.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
class Admin::EmojisController < Admin::AdminController
|
||||
|
||||
def index
|
||||
render_serialized(Emoji.custom, EmojiSerializer, root: false)
|
||||
end
|
||||
|
||||
def create
|
||||
file = params[:file] || params[:files].first
|
||||
name = params[:name] || File.basename(file.original_filename, ".*")
|
||||
|
||||
# fix the name
|
||||
name = name.gsub(/[^a-z0-9]+/i, '_')
|
||||
.gsub(/_{2,}/, '_')
|
||||
.downcase
|
||||
|
||||
# check the name doesn't already exist
|
||||
if Emoji.all.detect { |e| e.name == name }
|
||||
render json: failed_json.merge(message: I18n.t("emoji.errors.name_already_exists", name: name)), status: 422
|
||||
else
|
||||
if emoji = Emoji.create_for(file, name)
|
||||
render_serialized(emoji, EmojiSerializer, root: false)
|
||||
else
|
||||
render json: failed_json.merge(message: I18n.t("emoji.errors.error_while_storing_emoji")), status: 422
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def destroy
|
||||
name = params.require(:id)
|
||||
Emoji.custom.detect { |e| e.name == name }.try(:remove)
|
||||
render nothing: true
|
||||
end
|
||||
|
||||
end
|
||||
|
|
@ -255,6 +255,7 @@ class ApplicationController < ActionController::Base
|
|||
store_preloaded("siteSettings", SiteSetting.client_settings_json)
|
||||
store_preloaded("customHTML", custom_html_json)
|
||||
store_preloaded("banner", banner_json)
|
||||
store_preloaded("customEmoji", custom_emoji)
|
||||
end
|
||||
|
||||
def preload_current_user_data
|
||||
|
@ -281,7 +282,6 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def banner_json
|
||||
|
||||
json = ApplicationController.banner_json_cache["json"]
|
||||
|
||||
unless json
|
||||
|
@ -293,6 +293,11 @@ class ApplicationController < ActionController::Base
|
|||
json
|
||||
end
|
||||
|
||||
def custom_emoji
|
||||
serializer = ActiveModel::ArraySerializer.new(Emoji.custom, each_serializer: EmojiSerializer)
|
||||
MultiJson.dump(serializer)
|
||||
end
|
||||
|
||||
def render_json_error(obj)
|
||||
render json: MultiJson.dump(create_errors_json(obj)), status: 422
|
||||
end
|
||||
|
|
92
app/models/emoji.rb
Normal file
|
@ -0,0 +1,92 @@
|
|||
class Emoji
|
||||
include ActiveModel::SerializerSupport
|
||||
|
||||
attr_reader :path
|
||||
attr_accessor :name, :url
|
||||
|
||||
# whitelist emojis so that new user can post emojis
|
||||
Post::white_listed_image_classes << "emoji"
|
||||
|
||||
def initialize(path = nil)
|
||||
@path = path
|
||||
end
|
||||
|
||||
def remove
|
||||
return if path.blank?
|
||||
if File.exists?(path)
|
||||
File.delete(path) rescue nil
|
||||
Emoji.clear_cache
|
||||
end
|
||||
end
|
||||
|
||||
def self.all
|
||||
@all ||= standard | custom
|
||||
end
|
||||
|
||||
def self.standard
|
||||
@standard ||= load_standard
|
||||
end
|
||||
|
||||
def self.custom
|
||||
@custom ||= load_custom
|
||||
end
|
||||
|
||||
def self.create_from_path(path)
|
||||
extension = File.extname(path)
|
||||
Emoji.new(path).tap do |e|
|
||||
e.name = File.basename(path, ".*")
|
||||
e.url = "/#{base_url}/#{e.name}#{extension}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.create_from_db_item(emoji)
|
||||
name = emoji["aliases"].first
|
||||
filename = "#{name}.png"
|
||||
Emoji.new.tap do |e|
|
||||
e.name = name
|
||||
e.url = "/images/emoji/#{SiteSetting.emoji_set}/#{filename}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.create_for(file, name)
|
||||
extension = File.extname(file.original_filename)
|
||||
path = "#{Emoji.base_directory}/#{name}#{extension}"
|
||||
# store the emoji
|
||||
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
||||
File.open(path, "wb") { |f| f << file.tempfile.read }
|
||||
# clear the cache
|
||||
Emoji.clear_cache
|
||||
# return created emoji
|
||||
Emoji.custom.detect { |e| e.name == name }
|
||||
end
|
||||
|
||||
def self.clear_cache
|
||||
@custom = nil
|
||||
@all = nil
|
||||
end
|
||||
|
||||
def self.db_file
|
||||
"lib/emoji/db.json"
|
||||
end
|
||||
|
||||
def self.load_standard
|
||||
File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) }
|
||||
.map { |emoji| Emoji.create_from_db_item(emoji) }
|
||||
end
|
||||
|
||||
def self.load_custom
|
||||
Dir.glob(File.join(Emoji.base_directory, "*.{png,gif}"))
|
||||
.sort
|
||||
.map { |emoji| Emoji.create_from_path(emoji) }
|
||||
end
|
||||
|
||||
def self.base_directory
|
||||
"public/#{base_url}"
|
||||
end
|
||||
|
||||
def self.base_url
|
||||
db = RailsMultisite::ConnectionManagement.current_db
|
||||
"uploads/#{db}/_emoji"
|
||||
end
|
||||
|
||||
end
|
38
app/models/emoji_set_site_setting.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
require 'enum_site_setting'
|
||||
|
||||
class EmojiSetSiteSetting < EnumSiteSetting
|
||||
|
||||
# fix the URLs when changing the site setting
|
||||
DiscourseEvent.on(:site_setting_saved) do |site_setting|
|
||||
if site_setting.name.to_s == "emoji_set" && site_setting.value_changed?
|
||||
before = "/images/emoji/#{site_setting.value_was}/"
|
||||
after = "/images/emoji/#{site_setting.value}/"
|
||||
|
||||
Scheduler::Defer.later("Fix Emoji Links") do
|
||||
Post.exec_sql("UPDATE posts SET cooked = REPLACE(cooked, :before, :after) WHERE cooked LIKE :like",
|
||||
before: before,
|
||||
after: after,
|
||||
like: "%#{before}%"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.valid_value?(val)
|
||||
values.any? { |v| v[:value] == val.to_s }
|
||||
end
|
||||
|
||||
def self.values
|
||||
@values ||= [
|
||||
{ name: 'apple_international', value: 'apple' },
|
||||
{ name: 'google', value: 'google' },
|
||||
{ name: 'twitter', value: 'twitter' },
|
||||
{ name: 'emoji_one', value: 'emoji_one' },
|
||||
]
|
||||
end
|
||||
|
||||
def self.translate_names?
|
||||
true
|
||||
end
|
||||
|
||||
end
|
3
app/serializers/emoji_serializer.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class EmojiSerializer < ApplicationSerializer
|
||||
attributes :name, :url
|
||||
end
|
|
@ -36,6 +36,9 @@
|
|||
Discourse.Environment = '<%= Rails.env %>';
|
||||
Discourse.SiteSettings = PreloadStore.get('siteSettings');
|
||||
Discourse.LetterAvatarVersion = <%= LetterAvatar::VERSION %>;
|
||||
PreloadStore.get("customEmoji").forEach(function(emoji) {
|
||||
Discourse.Dialect.registerEmoji(emoji.name, emoji.url);
|
||||
});
|
||||
Discourse.Router = Ember.Router.extend({ location: 'discourse-location' });
|
||||
Discourse.Route.mapRoutes();
|
||||
Discourse.start();
|
||||
|
|
|
@ -646,7 +646,14 @@ en:
|
|||
title: "with GitHub"
|
||||
message: "Authenticating with GitHub (make sure pop up blockers are not enabled)"
|
||||
|
||||
apple_international: "Apple/International"
|
||||
google: "Google"
|
||||
twitter: "Twitter"
|
||||
emoji_one: "Emoji One"
|
||||
|
||||
composer:
|
||||
emoji: "Emoji :smile:"
|
||||
|
||||
add_warning: "This is an official warning."
|
||||
posting_not_on_topic: "Which topic do you want to reply to?"
|
||||
saving_draft_tip: "saving"
|
||||
|
@ -2179,6 +2186,14 @@ en:
|
|||
with_post_time: <span class="username">%{username}</span> for post in %{link} at <span class="time">%{time}</span>
|
||||
with_time: <span class="username">%{username}</span> at <span class="time">%{time}</span>
|
||||
|
||||
emoji:
|
||||
title: "Emoji"
|
||||
help: "Add new emoji that will be available to everyone. (PROTIP: drag & drop multiple files at once)"
|
||||
add: "Add New Emoji"
|
||||
name: "Name"
|
||||
image: "Image"
|
||||
delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"
|
||||
|
||||
lightbox:
|
||||
download: "download"
|
||||
|
||||
|
|
|
@ -1029,6 +1029,9 @@ en:
|
|||
|
||||
prevent_anons_from_downloading_files: "Prevent anonymous users from downloading attachments. WARNING: this will prevent any non-image site assets posted as attachments from working."
|
||||
|
||||
enable_emoji: "Enable emoji"
|
||||
emoji_set: "How would you like your emoji?"
|
||||
|
||||
errors:
|
||||
invalid_email: "Invalid email address."
|
||||
invalid_username: "There's no user with that username."
|
||||
|
@ -1089,6 +1092,11 @@ en:
|
|||
change_owner:
|
||||
post_revision_text: "Ownership transferred from %{old_user} to %{new_user}"
|
||||
|
||||
emoji:
|
||||
errors:
|
||||
name_already_exists: "Sorry, the name '%{name}' is already used by another emoji."
|
||||
error_while_storing_emoji: "Sorry, there has been an error while storing the emoji."
|
||||
|
||||
topic_statuses:
|
||||
archived_enabled: "This topic is now archived. It is frozen and cannot be changed in any way."
|
||||
archived_disabled: "This topic is now unarchived. It is no longer frozen, and can be changed."
|
||||
|
|
|
@ -125,6 +125,7 @@ Discourse::Application.routes.draw do
|
|||
resources :site_text, constraints: AdminConstraint.new
|
||||
resources :site_text_types, constraints: AdminConstraint.new
|
||||
resources :user_fields, constraints: AdminConstraint.new
|
||||
resources :emojis, constraints: AdminConstraint.new
|
||||
end
|
||||
|
||||
resources :color_schemes, constraints: AdminConstraint.new
|
||||
|
|
|
@ -389,6 +389,13 @@ posting:
|
|||
default: ''
|
||||
refresh: true
|
||||
type: list
|
||||
enable_emoji:
|
||||
default: true
|
||||
client: true
|
||||
emoji_set:
|
||||
default: 'emoji_one'
|
||||
client: true
|
||||
enum: 'EmojiSetSiteSetting'
|
||||
|
||||
email:
|
||||
email_time_window_mins:
|
||||
|
|
20
db/migrate/20141222224220_fix_emoji_path_take2.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
class FixEmojiPathTake2 < ActiveRecord::Migration
|
||||
OLD_URL = '/plugins/emoji/images/'
|
||||
NEW_URL = '/images/emoji/'
|
||||
|
||||
def up
|
||||
execute <<-SQL
|
||||
UPDATE posts
|
||||
SET cooked = REPLACE(cooked, '#{OLD_URL}', '#{NEW_URL}')
|
||||
WHERE cooked LIKE '%#{OLD_URL}%'
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
execute <<-SQL
|
||||
UPDATE posts
|
||||
SET cooked = REPLACE(cooked, '#{NEW_URL}', '#{OLD_URL}')
|
||||
WHERE cooked LIKE '%#{NEW_URL}%'
|
||||
SQL
|
||||
end
|
||||
end
|
|
@ -268,8 +268,8 @@ module BackupRestore
|
|||
end
|
||||
|
||||
def extract_uploads
|
||||
log "Extracting uploads..."
|
||||
if `tar --list --file #{@tar_filename} | grep 'uploads/'`.present?
|
||||
log "Extracting uploads..."
|
||||
FileUtils.cd(File.join(Rails.root, "public")) do
|
||||
`tar --extract --keep-newer-files --file #{@tar_filename} uploads/`
|
||||
end
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# Like a hash, just does its best to stay in sync across the farm
|
||||
# On boot all instances are blank, but they populate as various processes
|
||||
# fill it up
|
||||
#
|
||||
|
||||
require 'weakref'
|
||||
|
||||
|
@ -31,9 +30,9 @@ class DistributedCache
|
|||
hash = current.hash(message.site_id)
|
||||
|
||||
case payload["op"]
|
||||
when "set" then hash[payload["key"]] = payload["value"]
|
||||
when "set" then hash[payload["key"]] = payload["value"]
|
||||
when "delete" then hash.delete(payload["key"])
|
||||
when "clear" then hash.clear
|
||||
when "clear" then hash.clear
|
||||
end
|
||||
|
||||
rescue WeakRef::RefError
|
||||
|
@ -64,7 +63,7 @@ class DistributedCache
|
|||
def self.publish(hash, message)
|
||||
message[:origin] = hash.object_id
|
||||
message[:hash_key] = hash.key
|
||||
MessageBus.publish(channel_name, message, {user_ids: [-1]})
|
||||
MessageBus.publish(channel_name, message, { user_ids: [-1] })
|
||||
end
|
||||
|
||||
def self.set(hash, key, value)
|
||||
|
@ -72,11 +71,11 @@ class DistributedCache
|
|||
end
|
||||
|
||||
def self.delete(hash, key)
|
||||
publish(hash, { op: :delete, key: key})
|
||||
publish(hash, { op: :delete, key: key })
|
||||
end
|
||||
|
||||
def self.clear(hash)
|
||||
publish(hash, {op: :clear})
|
||||
publish(hash, { op: :clear })
|
||||
end
|
||||
|
||||
def self.register(hash)
|
||||
|
@ -93,7 +92,6 @@ class DistributedCache
|
|||
@data = {}
|
||||
end
|
||||
|
||||
|
||||
def []=(k,v)
|
||||
k = k.to_s if Symbol === k
|
||||
DistributedCache.set(self, k, v)
|
||||
|
@ -116,7 +114,6 @@ class DistributedCache
|
|||
hash.clear
|
||||
end
|
||||
|
||||
|
||||
def hash(db = nil)
|
||||
db ||= RailsMultisite::ConnectionManagement.current_db
|
||||
@data[db] ||= ThreadSafe::Hash.new
|
||||
|
|
|
@ -163,7 +163,7 @@ module Email
|
|||
img.remove
|
||||
end
|
||||
|
||||
if img['src'] =~ /plugins\/emoji/
|
||||
if img['src'] =~ /images\/emoji/
|
||||
img.replace img['title']
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,7 +9,7 @@ end
|
|||
def download_emojis_for(set, url_template, options={})
|
||||
puts "Downloading emojis for #{set}..."
|
||||
|
||||
path = "plugins/emoji/public/images/#{set}"
|
||||
path = "public/images/emoji/#{set}"
|
||||
FileUtils.rm_rf(path) rescue nil
|
||||
FileUtils.mkdir_p(path) rescue nil
|
||||
|
||||
|
@ -41,7 +41,7 @@ GOOGLE_EMOJIS = {35=>1, 48=>2, 49=>3, 50=>4, 51=>5, 52=>6, 53=>7, 54=>8, 55=>9,
|
|||
def download_google_emojis(url_template)
|
||||
puts "Downloading emojis for google..."
|
||||
|
||||
path = "plugins/emoji/public/images/google"
|
||||
path = "public/images/google"
|
||||
FileUtils.rm_rf(path) rescue nil
|
||||
FileUtils.mkdir_p(path) rescue nil
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Discourse Emoji Gem
|
||||
|
||||
Adds Emoji support to discourse. Thanks to the gemoji gem for the assets.
|
|
@ -1,22 +0,0 @@
|
|||
require 'enum_site_setting'
|
||||
|
||||
class EmojiSetSiteSetting < EnumSiteSetting
|
||||
|
||||
def self.valid_value?(val)
|
||||
values.any? { |v| v[:value] == val.to_s }
|
||||
end
|
||||
|
||||
def self.values
|
||||
@values ||= [
|
||||
{ name: 'apple_international', value: 'apple' },
|
||||
{ name: 'google', value: 'google' },
|
||||
{ name: 'twitter', value: 'twitter' },
|
||||
{ name: 'emoji_one', value: 'emoji_one' },
|
||||
]
|
||||
end
|
||||
|
||||
def self.translate_names?
|
||||
true
|
||||
end
|
||||
|
||||
end
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/env ruby
|
||||
# This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application.
|
||||
|
||||
ENGINE_ROOT = File.expand_path('../..', __FILE__)
|
||||
ENGINE_PATH = File.expand_path('../../lib/emoji/engine', __FILE__)
|
||||
|
||||
require 'rails/all'
|
||||
require 'rails/engine/commands'
|
|
@ -1,6 +0,0 @@
|
|||
de:
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
plugins: "Plug-ins"
|
|
@ -1,15 +0,0 @@
|
|||
en:
|
||||
js:
|
||||
composer:
|
||||
emoji: "Emoji :smile:"
|
||||
|
||||
apple_international: "Apple/International"
|
||||
google: "Google"
|
||||
twitter: "Twitter"
|
||||
emoji_one: "Emoji One"
|
||||
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
plugins: "Plugins"
|
|
@ -1,6 +0,0 @@
|
|||
pl_PL:
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
plugins: "Wtyczki"
|
|
@ -1,6 +0,0 @@
|
|||
ru:
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
plugins: "Плагины"
|
|
@ -1,6 +0,0 @@
|
|||
zh_CN:
|
||||
admin_js:
|
||||
admin:
|
||||
site_settings:
|
||||
categories:
|
||||
plugins: "插件"
|
|
@ -1,3 +0,0 @@
|
|||
de:
|
||||
site_settings:
|
||||
enable_emoji: "das Emoji-Plug-in aktivieren"
|
|
@ -1,4 +0,0 @@
|
|||
en:
|
||||
site_settings:
|
||||
enable_emoji: "Enable the emoji plugin"
|
||||
emoji_set: "How would you like your emoji?"
|
|
@ -1,3 +0,0 @@
|
|||
pl_PL:
|
||||
site_settings:
|
||||
enable_emoji: "Włącz wyświetlanie Emoji"
|
|
@ -1,3 +0,0 @@
|
|||
ru:
|
||||
site_settings:
|
||||
enable_emoji: "Включить плагин emoji"
|
|
@ -1,3 +0,0 @@
|
|||
zh_CN:
|
||||
site_settings:
|
||||
enable_emoji: "启用绘文字(emoji)插件"
|
|
@ -1,8 +0,0 @@
|
|||
plugins:
|
||||
enable_emoji:
|
||||
default: true
|
||||
client: true
|
||||
emoji_set:
|
||||
default: 'emoji_one'
|
||||
client: true
|
||||
enum: 'EmojiSetSiteSetting'
|
|
@ -1,4 +0,0 @@
|
|||
require "emoji/engine"
|
||||
|
||||
module Emoji
|
||||
end
|
|
@ -1,20 +0,0 @@
|
|||
module Emoji
|
||||
class Engine < ::Rails::Engine
|
||||
isolate_namespace Emoji
|
||||
end
|
||||
|
||||
def self.all
|
||||
return @all if defined?(@all)
|
||||
@all = parse_db
|
||||
end
|
||||
|
||||
def self.db_file
|
||||
File.expand_path('../../../db.json', __FILE__)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.parse_db
|
||||
File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) }
|
||||
end
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
module Emoji
|
||||
VERSION = "0.0.1"
|
||||
end
|
|
@ -1,33 +0,0 @@
|
|||
# name: emoji
|
||||
# about: emoji support for Discourse
|
||||
# version: 0.2
|
||||
# authors: Sam Saffron, Robin Ward, Régis Hanol
|
||||
|
||||
load File.expand_path('../lib/emoji/engine.rb', __FILE__)
|
||||
|
||||
register_asset('javascripts/emoji.js.erb', :server_side)
|
||||
register_asset('javascripts/emoji-autocomplete.js', :composer)
|
||||
register_asset('javascripts/discourse/templates/emoji-toolbar.raw.hbs', :composer)
|
||||
register_asset('javascripts/emoji-toolbar.js', :composer)
|
||||
register_asset('stylesheets/emoji.css')
|
||||
|
||||
def site_setting_saved(site_setting)
|
||||
return unless site_setting.name.to_s == "emoji_set"
|
||||
return unless site_setting.value_changed?
|
||||
before = "/plugins/emoji/images/#{site_setting.value_was}/"
|
||||
after = "/plugins/emoji/images/#{site_setting.value}/"
|
||||
Scheduler::Defer.later "Fix Emoji Links" do
|
||||
Post.exec_sql("UPDATE posts SET cooked = REPLACE(cooked, :before, :after) WHERE cooked LIKE :like",
|
||||
before: before,
|
||||
after: after,
|
||||
like: "%#{before}%"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
listen_for(:site_setting_saved)
|
||||
|
||||
after_initialize do
|
||||
# whitelist emojis so that new user can post emojis
|
||||
Post::white_listed_image_classes << "emoji"
|
||||
end
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |