FEATURE: Native theme support
This feature introduces the concept of themes. Themes are an evolution
of site customizations.
Themes introduce two very big conceptual changes:
- A theme may include other "child themes", children can include grand
children and so on.
- A theme may specify a color scheme
The change does away with the idea of "enabled" color schemes.
It also adds a bunch of big niceties like
- You can source a theme from a git repo
- History for themes is much improved
- You can only have a single enabled theme. Themes can be selected by
users, if you opt for it.
On a technical level this change comes with a whole bunch of goodies
- All CSS is now compiled using a custom pipeline that uses libsass
see /lib/stylesheet
- There is a single pipeline for css compilation (in the past we used
one for customizations and another one for the rest of the app
- The stylesheet pipeline is now divorced of sprockets, there is no
reliance on sprockets for CSS bundling
- CSS is generated with source maps everywhere (including themes) this
makes debugging much easier
- Our "live reloader" is smarter and avoid a flash of unstyled content
we run a file watcher in "puma" in dev so you no longer need to run
rake autospec to watch for CSS changes
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
@computed("model.colors","onlyOverridden")
|
||||
colors(allColors, onlyOverridden) {
|
||||
if (onlyOverridden) {
|
||||
return allColors.filter(color => color.get("overridden"));
|
||||
} else {
|
||||
return allColors;
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
revert: function(color) {
|
||||
color.revert();
|
||||
},
|
||||
|
||||
undo: function(color) {
|
||||
color.undo();
|
||||
},
|
||||
|
||||
copy() {
|
||||
var newColorScheme = Em.copy(this.get('model'), true);
|
||||
newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + this.get('model.name'));
|
||||
newColorScheme.save().then(()=>{
|
||||
this.get('allColors').pushObject(newColorScheme);
|
||||
this.replaceRoute('adminCustomize.colors.show', newColorScheme);
|
||||
});
|
||||
},
|
||||
|
||||
save: function() {
|
||||
this.get('model').save();
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
|
||||
return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
|
||||
if (result) {
|
||||
this.get('model').destroy().then(()=>{
|
||||
this.get('allColors').removeObject(this.get('model'));
|
||||
this.replaceRoute('adminCustomize.colors');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
export default Ember.Controller.extend({
|
||||
onlyOverridden: false,
|
||||
import showModal from 'discourse/lib/show-modal';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
baseColorScheme: function() {
|
||||
return this.get('model').findBy('is_base', true);
|
||||
}.property('model.@each.id'),
|
||||
|
||||
baseColorSchemes: function() {
|
||||
return this.get('model').filterBy('is_base', true);
|
||||
}.property('model.@each.id'),
|
||||
|
||||
baseColors: function() {
|
||||
var baseColorsHash = Em.Object.create({});
|
||||
_.each(this.get('baseColorScheme.colors'), function(color){
|
||||
@@ -13,99 +17,25 @@ export default Ember.Controller.extend({
|
||||
return baseColorsHash;
|
||||
}.property('baseColorScheme'),
|
||||
|
||||
removeSelected() {
|
||||
this.get('model').removeObject(this.get('selectedItem'));
|
||||
this.set('selectedItem', null);
|
||||
},
|
||||
|
||||
filterContent: function() {
|
||||
if (!this.get('selectedItem')) { return; }
|
||||
|
||||
if (!this.get('onlyOverridden')) {
|
||||
this.set('colors', this.get('selectedItem.colors'));
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
_.each(this.get('selectedItem.colors'), function(color){
|
||||
if (color.get('overridden')) matches.pushObject(color);
|
||||
});
|
||||
|
||||
this.set('colors', matches);
|
||||
}.observes('onlyOverridden'),
|
||||
|
||||
updateEnabled: function() {
|
||||
var selectedItem = this.get('selectedItem');
|
||||
if (selectedItem.get('enabled')) {
|
||||
this.get('model').forEach(function(c) {
|
||||
if (c !== selectedItem) {
|
||||
c.set('enabled', false);
|
||||
c.startTrackingChanges();
|
||||
c.notifyPropertyChange('description');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
selectColorScheme: function(colorScheme) {
|
||||
if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
|
||||
this.set('selectedItem', colorScheme);
|
||||
this.set('colors', colorScheme.get('colors'));
|
||||
colorScheme.set('savingStatus', null);
|
||||
colorScheme.set('selected', true);
|
||||
this.filterContent();
|
||||
|
||||
newColorSchemeWithBase(baseKey) {
|
||||
const base = this.get('baseColorSchemes').findBy('base_scheme_id', baseKey);
|
||||
const newColorScheme = Em.copy(base, true);
|
||||
newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
|
||||
newColorScheme.set('base_scheme_id', base.get('base_scheme_id'));
|
||||
newColorScheme.save().then(()=>{
|
||||
this.get('model').pushObject(newColorScheme);
|
||||
newColorScheme.set('savingStatus', null);
|
||||
this.replaceRoute('adminCustomize.colors.show', newColorScheme);
|
||||
});
|
||||
},
|
||||
|
||||
newColorScheme() {
|
||||
const newColorScheme = Em.copy(this.get('baseColorScheme'), true);
|
||||
newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
|
||||
this.get('model').pushObject(newColorScheme);
|
||||
this.send('selectColorScheme', newColorScheme);
|
||||
this.set('onlyOverridden', false);
|
||||
showModal('admin-color-scheme-select-base', { model: this.get('baseColorSchemes'), admin: true});
|
||||
},
|
||||
|
||||
revert: function(color) {
|
||||
color.revert();
|
||||
},
|
||||
|
||||
undo: function(color) {
|
||||
color.undo();
|
||||
},
|
||||
|
||||
toggleEnabled: function() {
|
||||
var selectedItem = this.get('selectedItem');
|
||||
selectedItem.toggleProperty('enabled');
|
||||
selectedItem.save({enabledOnly: true});
|
||||
this.updateEnabled();
|
||||
},
|
||||
|
||||
save: function() {
|
||||
this.get('selectedItem').save();
|
||||
this.updateEnabled();
|
||||
},
|
||||
|
||||
copy(colorScheme) {
|
||||
var newColorScheme = Em.copy(colorScheme, true);
|
||||
newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name'));
|
||||
this.get('model').pushObject(newColorScheme);
|
||||
this.send('selectColorScheme', newColorScheme);
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
var self = this,
|
||||
item = self.get('selectedItem');
|
||||
|
||||
return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
|
||||
if (result) {
|
||||
if (item.get('newRecord')) {
|
||||
self.removeSelected();
|
||||
} else {
|
||||
item.destroy().then(function(){ self.removeSelected(); });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { url } from 'discourse/lib/computed';
|
||||
|
||||
const sections = ['css', 'header', 'top', 'footer', 'head-tag', 'body-tag',
|
||||
'mobile-css', 'mobile-header', 'mobile-top', 'mobile-footer',
|
||||
'embedded-css'];
|
||||
|
||||
const activeSections = {};
|
||||
sections.forEach(function(s) {
|
||||
activeSections[Ember.String.camelize(s) + "Active"] = Ember.computed.equal('section', s);
|
||||
});
|
||||
|
||||
|
||||
export default Ember.Controller.extend(activeSections, {
|
||||
maximized: false,
|
||||
section: null,
|
||||
|
||||
previewUrl: url("model.key", "/?preview-style=%@"),
|
||||
downloadUrl: url('model.id', '/admin/site_customizations/%@'),
|
||||
|
||||
mobile: function() {
|
||||
return this.get('section').indexOf('mobile-') === 0;
|
||||
}.property('section'),
|
||||
|
||||
maximizeIcon: function() {
|
||||
return this.get('maximized') ? 'compress' : 'expand';
|
||||
}.property('maximized'),
|
||||
|
||||
saveButtonText: function() {
|
||||
return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save');
|
||||
}.property('model.isSaving'),
|
||||
|
||||
saveDisabled: function() {
|
||||
return !this.get('model.changed') || this.get('model.isSaving');
|
||||
}.property('model.changed', 'model.isSaving'),
|
||||
|
||||
adminCustomizeCssHtml: Ember.inject.controller(),
|
||||
|
||||
undoPreviewUrl: url('/?preview-style='),
|
||||
defaultStyleUrl: url('/?preview-style=default'),
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
this.get('model').saveChanges();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
|
||||
if (result) {
|
||||
const model = this.get('model');
|
||||
model.destroyRecord().then(() => {
|
||||
this.get('adminCustomizeCssHtml').get('model').removeObject(model);
|
||||
this.transitionToRoute('adminCustomizeCssHtml');
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleMaximize: function() {
|
||||
this.toggleProperty('maximized');
|
||||
},
|
||||
|
||||
toggleMobile: function() {
|
||||
const section = this.get('section');
|
||||
|
||||
// Try to send to the same tab as before
|
||||
let dest;
|
||||
if (this.get('mobile')) {
|
||||
dest = section.replace('mobile-', '');
|
||||
if (sections.indexOf(dest) === -1) { dest = 'css'; }
|
||||
} else {
|
||||
dest = 'mobile-' + section;
|
||||
if (sections.indexOf(dest) === -1) { dest = 'mobile-css'; }
|
||||
}
|
||||
this.replaceRoute('adminCustomizeCssHtml.show', this.get('model.id'), dest);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { url } from 'discourse/lib/computed';
|
||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
maximized: false,
|
||||
section: null,
|
||||
|
||||
targets: [
|
||||
{id: 0, name: I18n.t('admin.customize.theme.common')},
|
||||
{id: 1, name: I18n.t('admin.customize.theme.desktop')},
|
||||
{id: 2, name: I18n.t('admin.customize.theme.mobile')}
|
||||
],
|
||||
|
||||
currentTarget: 0,
|
||||
|
||||
setTargetName: function(name) {
|
||||
let target;
|
||||
switch(name) {
|
||||
case "common": target = 0; break;
|
||||
case "desktop": target = 1; break;
|
||||
case "mobile": target = 2; break;
|
||||
}
|
||||
|
||||
this.set("currentTarget", target);
|
||||
},
|
||||
|
||||
@computed("currentTarget")
|
||||
currentTargetName(target) {
|
||||
switch(parseInt(target)) {
|
||||
case 0: return "common";
|
||||
case 1: return "desktop";
|
||||
case 2: return "mobile";
|
||||
}
|
||||
},
|
||||
|
||||
@computed("fieldName")
|
||||
activeSectionMode(fieldName) {
|
||||
return fieldName && fieldName.indexOf("scss") > -1 ? "css" : "html";
|
||||
},
|
||||
|
||||
@computed("fieldName", "currentTargetName", "model")
|
||||
activeSection: {
|
||||
get(fieldName, target, model) {
|
||||
return model.getField(target, fieldName);
|
||||
},
|
||||
set(value, fieldName, target, model) {
|
||||
model.setField(target, fieldName, value);
|
||||
return value;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@computed("currentTarget")
|
||||
fields(target) {
|
||||
let fields = [
|
||||
"scss", "head_tag", "header", "after_header", "body_tag", "footer"
|
||||
];
|
||||
|
||||
if (parseInt(target) === 0) {
|
||||
fields.push("embedded_scss");
|
||||
}
|
||||
|
||||
return fields.map(name=>{
|
||||
let hash = {
|
||||
key: (`admin.customize.theme.${name}.text`),
|
||||
name: name
|
||||
};
|
||||
|
||||
if (name.indexOf("_tag") > 0) {
|
||||
hash.icon = "file-text-o";
|
||||
}
|
||||
|
||||
hash.title = I18n.t(`admin.customize.theme.${name}.title`);
|
||||
|
||||
return hash;
|
||||
});
|
||||
},
|
||||
|
||||
previewUrl: url('model.key', '/?preview-style=%@'),
|
||||
|
||||
maximizeIcon: function() {
|
||||
return this.get('maximized') ? 'compress' : 'expand';
|
||||
}.property('maximized'),
|
||||
|
||||
saveButtonText: function() {
|
||||
return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save');
|
||||
}.property('model.isSaving'),
|
||||
|
||||
saveDisabled: function() {
|
||||
return !this.get('model.changed') || this.get('model.isSaving');
|
||||
}.property('model.changed', 'model.isSaving'),
|
||||
|
||||
undoPreviewUrl: url('/?preview-style='),
|
||||
defaultStyleUrl: url('/?preview-style=default'),
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
this.get('model').saveChanges("theme_fields");
|
||||
},
|
||||
|
||||
toggleMaximize: function() {
|
||||
this.toggleProperty('maximized');
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||
import { url } from 'discourse/lib/computed';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
|
||||
@computed("model.theme_fields.@each")
|
||||
hasEditedFields(fields) {
|
||||
return fields.any(f=>!Em.isBlank(f.value));
|
||||
},
|
||||
|
||||
@computed('model.theme_fields.@each')
|
||||
editedDescriptions(fields) {
|
||||
let descriptions = [];
|
||||
let description = target => {
|
||||
let current = fields.filter(field => field.target === target && !Em.isBlank(field.value));
|
||||
if (current.length > 0) {
|
||||
let text = I18n.t('admin.customize.theme.'+target);
|
||||
let localized = current.map(f=>I18n.t('admin.customize.theme.'+f.name + '.text'));
|
||||
return text + ": " + localized.join(" , ");
|
||||
}
|
||||
};
|
||||
['common','desktop','mobile'].forEach(target=> {
|
||||
descriptions.push(description(target));
|
||||
});
|
||||
return descriptions.reject(d=>Em.isBlank(d));
|
||||
},
|
||||
|
||||
@computed("colorSchemeId", "model.color_scheme_id")
|
||||
colorSchemeChanged(colorSchemeId, existingId) {
|
||||
colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId);
|
||||
return colorSchemeId !== existingId;
|
||||
},
|
||||
|
||||
@computed("availableChildThemes", "model.childThemes.@each", "model", "allowChildThemes")
|
||||
selectableChildThemes(available, childThemes, model, allowChildThemes) {
|
||||
if (!allowChildThemes && (!childThemes || childThemes.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let themes = [];
|
||||
available.forEach(t=> {
|
||||
if (!childThemes || (childThemes.indexOf(t) === -1)) {
|
||||
themes.push(t);
|
||||
};
|
||||
});
|
||||
return themes.length === 0 ? null : themes;
|
||||
},
|
||||
|
||||
showSchemes: Em.computed.or("model.default", "model.user_selectable"),
|
||||
|
||||
@computed("allThemes", "allThemes.length", "model")
|
||||
availableChildThemes(allThemes, count) {
|
||||
if (count === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let excludeIds = [this.get("model.id")];
|
||||
|
||||
let themes = [];
|
||||
allThemes.forEach(theme => {
|
||||
if (excludeIds.indexOf(theme.get("id")) === -1) {
|
||||
themes.push(theme);
|
||||
}
|
||||
});
|
||||
|
||||
return themes;
|
||||
},
|
||||
|
||||
downloadUrl: url('model.id', '/admin/themes/%@'),
|
||||
|
||||
actions: {
|
||||
|
||||
updateToLatest() {
|
||||
this.set("updatingRemote", true);
|
||||
this.get("model").updateToLatest().finally(()=>{
|
||||
this.set("updatingRemote", false);
|
||||
});
|
||||
},
|
||||
|
||||
checkForThemeUpdates() {
|
||||
this.set("updatingRemote", true);
|
||||
this.get("model").checkForUpdates().finally(()=>{
|
||||
this.set("updatingRemote", false);
|
||||
});
|
||||
},
|
||||
|
||||
cancelChangeScheme() {
|
||||
this.set("colorSchemeId", this.get("model.color_scheme_id"));
|
||||
},
|
||||
changeScheme(){
|
||||
let schemeId = this.get("colorSchemeId");
|
||||
this.set("model.color_scheme_id", schemeId === null ? null : parseInt(schemeId));
|
||||
this.get("model").saveChanges("color_scheme_id");
|
||||
},
|
||||
startEditingName() {
|
||||
this.set("oldName", this.get("model.name"));
|
||||
this.set("editingName", true);
|
||||
},
|
||||
cancelEditingName() {
|
||||
this.set("model.name", this.get("oldName"));
|
||||
this.set("editingName", false);
|
||||
},
|
||||
finishedEditingName() {
|
||||
this.get("model").saveChanges("name");
|
||||
this.set("editingName", false);
|
||||
},
|
||||
|
||||
editTheme() {
|
||||
let edit = ()=>this.transitionToRoute('adminCustomizeThemes.edit', {model: this.get('model')});
|
||||
|
||||
if (this.get("model.remote_theme")) {
|
||||
bootbox.confirm(I18n.t("admin.customize.theme.edit_confirm"), result => {
|
||||
if (result) {
|
||||
edit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
edit();
|
||||
}
|
||||
},
|
||||
|
||||
applyDefault() {
|
||||
const model = this.get("model");
|
||||
model.saveChanges("default").then(()=>{
|
||||
if (model.get("default")) {
|
||||
this.get("allThemes").forEach(theme=>{
|
||||
if (theme !== model && theme.get('default')) {
|
||||
theme.set("default", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
applyUserSelectable() {
|
||||
this.get("model").saveChanges("user_selectable");
|
||||
},
|
||||
|
||||
addChildTheme() {
|
||||
let themeId = parseInt(this.get("selectedChildThemeId"));
|
||||
let theme = this.get("allThemes").findBy("id", themeId);
|
||||
this.get("model").addChildTheme(theme);
|
||||
},
|
||||
|
||||
removeChildTheme(theme) {
|
||||
this.get("model").removeChildTheme(theme);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => {
|
||||
if (result) {
|
||||
const model = this.get('model');
|
||||
model.destroyRecord().then(() => {
|
||||
this.get('allThemes').removeObject(model);
|
||||
this.transitionToRoute('adminCustomizeThemes');
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import ModalFunctionality from 'discourse/mixins/modal-functionality';
|
||||
|
||||
export default Ember.Controller.extend(ModalFunctionality, {
|
||||
|
||||
adminCustomizeColors: Ember.inject.controller(),
|
||||
|
||||
actions: {
|
||||
selectBase() {
|
||||
this.get('adminCustomizeColors')
|
||||
.send('newColorSchemeWithBase', this.get('selectedBaseThemeId'));
|
||||
this.send('closeModal');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import ModalFunctionality from 'discourse/mixins/modal-functionality';
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
// import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Controller.extend(ModalFunctionality, {
|
||||
local: Ember.computed.equal('selection', 'local'),
|
||||
remote: Ember.computed.equal('selection', 'remote'),
|
||||
selection: 'local',
|
||||
adminCustomizeThemes: Ember.inject.controller(),
|
||||
|
||||
actions: {
|
||||
importTheme() {
|
||||
|
||||
let options = {
|
||||
type: 'POST'
|
||||
};
|
||||
|
||||
if (this.get('local')) {
|
||||
options.processData = false;
|
||||
options.contentType = false;
|
||||
options.data = new FormData();
|
||||
options.data.append('theme', $('#file-input')[0].files[0]);
|
||||
} else {
|
||||
options.data = {remote: this.get('uploadUrl')};
|
||||
}
|
||||
|
||||
ajax('/admin/themes/import', options).then(result=>{
|
||||
const theme = this.store.createRecord('theme',result.theme);
|
||||
this.get('adminCustomizeThemes').send('addTheme', theme);
|
||||
this.send('closeModal');
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import ModalFunctionality from 'discourse/mixins/modal-functionality';
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
|
||||
export default Ember.Controller.extend(ModalFunctionality, {
|
||||
loadDiff() {
|
||||
this.set('loading', true);
|
||||
ajax('/admin/logs/staff_action_logs/' + this.get('model.id') + '/diff')
|
||||
.then(diff=>{
|
||||
this.set('loading', false);
|
||||
this.set('diff', diff.side_by_side);
|
||||
});
|
||||
}
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import ModalFunctionality from 'discourse/mixins/modal-functionality';
|
||||
|
||||
export default Ember.Controller.extend(ModalFunctionality, {
|
||||
previousSelected: Ember.computed.equal('selectedTab', 'previous'),
|
||||
newSelected: Ember.computed.equal('selectedTab', 'new'),
|
||||
|
||||
onShow: function() {
|
||||
this.send("selectNew");
|
||||
},
|
||||
|
||||
actions: {
|
||||
selectNew: function() {
|
||||
this.set('selectedTab', 'new');
|
||||
},
|
||||
|
||||
selectPrevious: function() {
|
||||
this.set('selectedTab', 'previous');
|
||||
}
|
||||
}
|
||||
});
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details";
|
||||
|
||||
export default ChangeSiteCustomizationDetailsController.extend({
|
||||
onShow() {
|
||||
this.send("selectPrevious");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user