first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import $ from 'jquery';
|
||||
import { config } from 'grav-config';
|
||||
import request from '../utils/request';
|
||||
|
||||
const body = $('body');
|
||||
|
||||
// Dashboard update and Grav update
|
||||
body.on('click', '[data-2fa-regenerate]', function(event) {
|
||||
event.preventDefault();
|
||||
let element = $(this);
|
||||
let url = `${config.base_url_relative}/ajax.json/task${config.param_sep}regenerate2FASecret`;
|
||||
|
||||
element.attr('disabled', 'disabled').find('> .fa').addClass('fa-spin');
|
||||
|
||||
request(url, { method: 'post' }, (response) => {
|
||||
$('[data-2fa-image]').attr('src', response.image);
|
||||
$('[data-2fa-secret]').text(response.secret);
|
||||
$('[data-2fa-value]').val(response.secret.replace(' ', ''));
|
||||
|
||||
element.removeAttr('disabled').find('> .fa').removeClass('fa-spin');
|
||||
});
|
||||
});
|
||||
|
||||
const toggleSecret = () => {
|
||||
const toggle = $('#toggle_twofa_enabled1');
|
||||
const secret = $('.twofa-secret');
|
||||
|
||||
secret[toggle.is(':checked') ? 'addClass' : 'removeClass']('show');
|
||||
};
|
||||
|
||||
body.on('click', '.twofa-toggle input', toggleSecret);
|
||||
toggleSecret();
|
||||
@@ -0,0 +1,152 @@
|
||||
// Parses a string and returns a valid hex string when possible
|
||||
// parseHex('#fff') => '#ffffff'
|
||||
export const parseHex = (string) => {
|
||||
string = string.replace(/[^A-F0-9]/ig, '');
|
||||
if (string.length !== 3 && string.length !== 6) return '';
|
||||
if (string.length === 3) {
|
||||
string = string[0] + string[0] + string[1] + string[1] + string[2] + string[2];
|
||||
}
|
||||
|
||||
return '#' + string.toLowerCase();
|
||||
};
|
||||
|
||||
// Converts an HSB object to an RGB object
|
||||
// hsb2rgb({h: 0, s: 0, b: 100}) => {r: 255, g: 255, b: 255}
|
||||
export const hsb2rgb = (hsb) => {
|
||||
let rgb = {};
|
||||
let h = Math.round(hsb.h);
|
||||
let s = Math.round(hsb.s * 255 / 100);
|
||||
let v = Math.round(hsb.b * 255 / 100);
|
||||
if (s === 0) {
|
||||
rgb.r = rgb.g = rgb.b = v;
|
||||
} else {
|
||||
var t1 = v;
|
||||
var t2 = (255 - s) * v / 255;
|
||||
var t3 = (t1 - t2) * (h % 60) / 60;
|
||||
if (h === 360) h = 0;
|
||||
if (h < 60) {
|
||||
rgb.r = t1;
|
||||
rgb.b = t2;
|
||||
rgb.g = t2 + t3;
|
||||
} else if (h < 120) {
|
||||
rgb.g = t1;
|
||||
rgb.b = t2;
|
||||
rgb.r = t1 - t3;
|
||||
} else if (h < 180) {
|
||||
rgb.g = t1;
|
||||
rgb.r = t2;
|
||||
rgb.b = t2 + t3;
|
||||
} else if (h < 240) {
|
||||
rgb.b = t1;
|
||||
rgb.r = t2;
|
||||
rgb.g = t1 - t3;
|
||||
} else if (h < 300) {
|
||||
rgb.b = t1;
|
||||
rgb.g = t2;
|
||||
rgb.r = t2 + t3;
|
||||
} else if (h < 360) {
|
||||
rgb.r = t1;
|
||||
rgb.g = t2;
|
||||
rgb.b = t1 - t3;
|
||||
} else {
|
||||
rgb.r = 0;
|
||||
rgb.g = 0;
|
||||
rgb.b = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
r: Math.round(rgb.r),
|
||||
g: Math.round(rgb.g),
|
||||
b: Math.round(rgb.b)
|
||||
};
|
||||
};
|
||||
|
||||
// Converts an RGB object to a HEX string
|
||||
// rgb2hex({r: 255, g: 255, b: 255}) => #ffffff
|
||||
export const rgb2hex = (rgb) => {
|
||||
var hex = [
|
||||
rgb.r.toString(16),
|
||||
rgb.g.toString(16),
|
||||
rgb.b.toString(16)
|
||||
];
|
||||
|
||||
hex.forEach((val, nr) => {
|
||||
if (val.length === 1) hex[nr] = '0' + val;
|
||||
});
|
||||
|
||||
return '#' + hex.join('');
|
||||
};
|
||||
|
||||
// Converts and RGB(a) string to a HEX string
|
||||
// rgbstr2hex('rgba(255, 255, 255, 0.5)') => #ffffff
|
||||
export const rgbstr2hex = (rgb) => {
|
||||
rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
|
||||
|
||||
return (rgb && rgb.length === 4) ? '#' +
|
||||
('0' + parseInt(rgb[1], 10).toString(16)).slice(-2) +
|
||||
('0' + parseInt(rgb[2], 10).toString(16)).slice(-2) +
|
||||
('0' + parseInt(rgb[3], 10).toString(16)).slice(-2) : '';
|
||||
};
|
||||
|
||||
// Converts an HSB object to a HEX string
|
||||
// hsb2hex({h: 0, s: 0, b: 100}) => #ffffff
|
||||
export const hsb2hex = (hsb) => {
|
||||
return rgb2hex(hsb2rgb(hsb));
|
||||
};
|
||||
|
||||
// Converts a HEX string to an HSB object
|
||||
// hex2hsb('#ffffff') => {h: 0, s: 0, b: 100}
|
||||
export const hex2hsb = (hex) => {
|
||||
let hsb = rgb2hsb(hex2rgb(hex));
|
||||
if (hsb.s === 0) hsb.h = 360;
|
||||
|
||||
return hsb;
|
||||
};
|
||||
|
||||
// Converts an RGB object to an HSB object
|
||||
// rgb2hsb({r: 255, g: 255, b: 255}) => {h: 0, s: 0, b: 100}
|
||||
export const rgb2hsb = (rgb) => {
|
||||
let hsb = {
|
||||
h: 0,
|
||||
s: 0,
|
||||
b: 0
|
||||
};
|
||||
let min = Math.min(rgb.r, rgb.g, rgb.b);
|
||||
let max = Math.max(rgb.r, rgb.g, rgb.b);
|
||||
let delta = max - min;
|
||||
hsb.b = max;
|
||||
hsb.s = max !== 0 ? 255 * delta / max : 0;
|
||||
if (hsb.s !== 0) {
|
||||
if (rgb.r === max) {
|
||||
hsb.h = (rgb.g - rgb.b) / delta;
|
||||
} else if (rgb.g === max) {
|
||||
hsb.h = 2 + (rgb.b - rgb.r) / delta;
|
||||
} else {
|
||||
hsb.h = 4 + (rgb.r - rgb.g) / delta;
|
||||
}
|
||||
} else {
|
||||
hsb.h = -1;
|
||||
}
|
||||
hsb.h *= 60;
|
||||
if (hsb.h < 0) {
|
||||
hsb.h += 360;
|
||||
}
|
||||
hsb.s *= 100 / 255;
|
||||
hsb.b *= 100 / 255;
|
||||
|
||||
return hsb;
|
||||
};
|
||||
|
||||
// Converts a HEX string to an RGB object
|
||||
// hex2rgb('#ffffff') => {r: 255, g: 255, b: 255}
|
||||
export const hex2rgb = (hex) => {
|
||||
hex = parseInt(((hex.indexOf('#') > -1) ? hex.substring(1) : hex), 16);
|
||||
|
||||
return {
|
||||
/* jshint ignore:start */
|
||||
r: hex >> 16,
|
||||
g: (hex & 0x00FF00) >> 8,
|
||||
b: (hex & 0x0000FF)
|
||||
/* jshint ignore:end */
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
export default function formatBytes(bytes, decimals) {
|
||||
if (bytes === 0) return '0 Byte';
|
||||
|
||||
let k = 1000;
|
||||
let value = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
let decimal = decimals + 1 || 3;
|
||||
|
||||
return (bytes / Math.pow(k, value)).toPrecision(decimal) + ' ' + sizes[value];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { parseJSON, parseStatus, userFeedbackError } from './response';
|
||||
import { config } from 'grav-config';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export default class GPM extends EventEmitter {
|
||||
constructor(action = 'getUpdates') {
|
||||
super();
|
||||
this.payload = {};
|
||||
this.raw = {};
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
setPayload(payload = {}) {
|
||||
this.payload = payload;
|
||||
this.emit('payload', payload);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
setAction(action = 'getUpdates') {
|
||||
this.action = action;
|
||||
this.emit('action', action);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
fetch(callback = () => true, flush = false) {
|
||||
let data = new FormData();
|
||||
data.append('admin-nonce', config.admin_nonce);
|
||||
|
||||
if (flush) {
|
||||
data.append('flush', true);
|
||||
}
|
||||
|
||||
this.emit('fetching', this);
|
||||
|
||||
fetch(`${config.base_url_relative}/update.json/task${config.param_sep}getUpdates`, {
|
||||
credentials: 'same-origin',
|
||||
method: 'post',
|
||||
body: data
|
||||
}).then((response) => { this.raw = response; return response; })
|
||||
.then(parseStatus)
|
||||
.then(parseJSON)
|
||||
.then((response) => this.response(response))
|
||||
.then((response) => callback(response, this.raw))
|
||||
.then((response) => this.emit('fetched', this.payload, this.raw, this))
|
||||
.catch(userFeedbackError);
|
||||
}
|
||||
|
||||
response(response) {
|
||||
this.payload = response;
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export let Instance = new GPM();
|
||||
@@ -0,0 +1,50 @@
|
||||
import $ from 'jquery';
|
||||
import getSlug from 'speakingurl';
|
||||
|
||||
// jQuery no parents filter
|
||||
$.expr[':']['noparents'] = $.expr.createPseudo((text) => (element) => $(element).parents(text).length < 1);
|
||||
|
||||
// Slugify
|
||||
// CommonJS and ES6 version of https://github.com/madflow/jquery-slugify
|
||||
$.fn.slugify = (source, options) => {
|
||||
return this.each((element) => {
|
||||
let target = $(element);
|
||||
let source = $(source);
|
||||
|
||||
target.on('keyup change', () => {
|
||||
target.data('locked', target.val() !== '' && target.val() !== undefined);
|
||||
});
|
||||
|
||||
source.on('keyup change', () => {
|
||||
if (target.data('locked') === true) { return true; }
|
||||
|
||||
let isInput = target.is('input') || target.is('textarea');
|
||||
target[isInput ? 'val' : 'text']($.slugify(source.val(), options));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Static method.
|
||||
$.slugify = (sourceString, options) => {
|
||||
options = $.extend({}, $.slugify.options, options);
|
||||
options.lang = options.lang || $('html').prop('lang');
|
||||
|
||||
if (typeof options.preSlug === 'function') {
|
||||
sourceString = options.preSlug(sourceString);
|
||||
}
|
||||
|
||||
sourceString = options.slugFunc(sourceString, options);
|
||||
|
||||
if (typeof options.postSlug === 'function') {
|
||||
sourceString = options.postSlug(sourceString);
|
||||
}
|
||||
|
||||
return sourceString;
|
||||
};
|
||||
|
||||
// Default plugin options
|
||||
$.slugify.options = {
|
||||
preSlug: null,
|
||||
postSlug: null,
|
||||
slugFunc: (input, opts) => getSlug(input, opts)
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { config } from 'grav-config';
|
||||
import { userFeedbackError } from './response';
|
||||
|
||||
class KeepAlive {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
let timeout = config.admin_timeout / 1.5 * 1000;
|
||||
this.timer = setInterval(() => this.fetch(), timeout);
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this.timer);
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
fetch() {
|
||||
let data = new FormData();
|
||||
data.append('admin-nonce', config.admin_nonce);
|
||||
|
||||
fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
|
||||
credentials: 'same-origin',
|
||||
method: 'post',
|
||||
body: data
|
||||
}).catch(userFeedbackError);
|
||||
}
|
||||
}
|
||||
|
||||
export default new KeepAlive();
|
||||
@@ -0,0 +1,21 @@
|
||||
import $ from 'jquery';
|
||||
import isOnline from '../utils/offline';
|
||||
|
||||
const offlineElement = $('#offline-status');
|
||||
|
||||
$(window).on('offline', () => {
|
||||
offlineElement.slideDown();
|
||||
});
|
||||
|
||||
$(window).on('online', () => {
|
||||
offlineElement.slideUp();
|
||||
});
|
||||
|
||||
$(document).ready(() => {
|
||||
if (!isOnline) {
|
||||
offlineElement.slideDown();
|
||||
}
|
||||
});
|
||||
|
||||
// assume online if can't check
|
||||
export default typeof global.navigator.onLine !== 'undefined' ? global.navigator.onLine : true;
|
||||
@@ -0,0 +1,458 @@
|
||||
import $ from 'jquery';
|
||||
import { config, translations } from 'grav-config';
|
||||
import request from '../utils/request';
|
||||
import { Instance as gpm } from '../utils/gpm';
|
||||
import { Promise } from 'es6-promise';
|
||||
|
||||
class Sorter {
|
||||
getElements(elements, container) {
|
||||
this.elements = elements || $('[data-gpm-plugin], [data-gpm-theme]');
|
||||
this.container = container || $('.gpm-plugins > table > tbody, .gpm-themes > .themes.card-row');
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
static sort(A, B, direction = 'asc') {
|
||||
if (A > B) { return (direction === 'asc') ? 1 : -1; }
|
||||
if (A < B) { return (direction === 'asc') ? -1 : 1; }
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
byCommon(direction = 'asc', data = '') {
|
||||
let elements = this.getElements().sort((a, b) => {
|
||||
let A = $(a).data(data).toString().toLowerCase();
|
||||
let B = $(b).data(data).toString().toLowerCase();
|
||||
|
||||
return Sorter.sort(A, B, direction);
|
||||
});
|
||||
|
||||
return elements.appendTo(this.container);
|
||||
}
|
||||
|
||||
byName(direction = 'asc', data = 'gpm-name') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
|
||||
byAuthor(direction = 'asc', data = 'gpm-author') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
|
||||
byOfficial(direction = 'asc', data = 'gpm-official') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
|
||||
byReleaseDate(direction = 'asc', data = 'gpm-release-date') {
|
||||
let elements = this.getElements().sort((a, b) => {
|
||||
let A = new Date($(a).data(data)).getTime();
|
||||
let B = new Date($(b).data(data)).getTime();
|
||||
|
||||
return Sorter.sort(A, B, direction === 'asc' ? 'desc' : 'asc');
|
||||
});
|
||||
|
||||
elements.appendTo(this.container);
|
||||
}
|
||||
|
||||
byUpdatable(direction = 'asc', data = 'gpm-updatable') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
|
||||
byEnabled(direction = 'asc', data = 'gpm-enabled') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
|
||||
byTesting(direction = 'asc', data = 'gpm-testing') {
|
||||
return this.byCommon(direction, data);
|
||||
}
|
||||
}
|
||||
|
||||
class Packages {
|
||||
constructor() {
|
||||
this.Sort = new Sorter();
|
||||
}
|
||||
|
||||
static getBackToList(type) {
|
||||
global.location.href = `${config.base_url_relative}/${type}s`;
|
||||
}
|
||||
|
||||
static addDependencyToList(type, dependency, slug = '') {
|
||||
if (['admin', 'form', 'login', 'email', 'grav'].indexOf(dependency) !== -1) { return; }
|
||||
let container = $('.package-dependencies-container');
|
||||
let text = `${dependency} <a href="#" class="button" data-dependency-slug="${dependency}" data-${type}-action="remove-dependency-package">Remove</a>`;
|
||||
|
||||
if (slug) {
|
||||
text += ` (was needed by ${slug})`;
|
||||
}
|
||||
|
||||
container.append(`<li>${text}</li>`);
|
||||
}
|
||||
|
||||
addDependenciesToList(dependencies, slug = '') {
|
||||
dependencies.forEach((dependency) => {
|
||||
Packages.addDependencyToList('plugin', dependency.name || dependency, slug);
|
||||
});
|
||||
}
|
||||
|
||||
static getTaskUrl(type, task) {
|
||||
let url = `${config.base_url_relative}`;
|
||||
url += `/${type}s.json`;
|
||||
url += `/task${config.param_sep}${task}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
static getRemovePackageUrl(type) {
|
||||
return `${Packages.getTaskUrl(type, 'removePackage')}`;
|
||||
}
|
||||
|
||||
static getReinstallPackageUrl(type) {
|
||||
return `${Packages.getTaskUrl(type, 'reinstallPackage')}`;
|
||||
}
|
||||
|
||||
static getGetPackagesDependenciesUrl(type) {
|
||||
return `${Packages.getTaskUrl(type, 'getPackagesDependencies')}`;
|
||||
}
|
||||
|
||||
static getInstallDependenciesOfPackagesUrl(type) {
|
||||
return `${Packages.getTaskUrl(type, 'installDependenciesOfPackages')}`;
|
||||
}
|
||||
|
||||
static getInstallPackageUrl(type) {
|
||||
return `${Packages.getTaskUrl(type, 'installPackage')}`;
|
||||
}
|
||||
|
||||
removePackage(type, slug) {
|
||||
let url = Packages.getRemovePackageUrl(type);
|
||||
|
||||
request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
package: slug
|
||||
}
|
||||
}, (response) => {
|
||||
if (response.status === 'success') {
|
||||
$('.remove-package-confirm').addClass('hidden');
|
||||
|
||||
if (response.dependencies && response.dependencies.length > 0) {
|
||||
this.addDependenciesToList(response.dependencies);
|
||||
$('.remove-package-dependencies').removeClass('hidden');
|
||||
} else {
|
||||
$('.remove-package-done').removeClass('hidden');
|
||||
}
|
||||
|
||||
// The package was removed. When the modal closes, move to the packages list
|
||||
$(document).on('closing', '[data-remodal-id="remove-package"]', () => {
|
||||
Packages.getBackToList(type);
|
||||
});
|
||||
} else {
|
||||
$('.remove-package-confirm').addClass('hidden');
|
||||
$('.remove-package-error').removeClass('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reinstallPackage(type, slug, package_name, current_version) {
|
||||
$('.button-bar button').addClass('hidden');
|
||||
$('.button-bar .spinning-wheel').removeClass('hidden');
|
||||
|
||||
let url = Packages.getReinstallPackageUrl(type);
|
||||
|
||||
request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
slug: slug,
|
||||
type: type,
|
||||
package_name: package_name,
|
||||
current_version: current_version
|
||||
}
|
||||
}, (response) => {
|
||||
if (response.status === 'success') {
|
||||
$('.reinstall-package-confirm').addClass('hidden');
|
||||
$('.reinstall-package-done').removeClass('hidden');
|
||||
} else {
|
||||
$('.reinstall-package-confirm').addClass('hidden');
|
||||
$('.reinstall-package-error').removeClass('hidden');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
removeDependency(type, slug, button) {
|
||||
let url = Packages.getRemovePackageUrl(type);
|
||||
|
||||
request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
package: slug
|
||||
}
|
||||
}, (response) => {
|
||||
if (response.status === 'success') {
|
||||
button.removeClass('button');
|
||||
button.replaceWith($('<span>Removed successfully</span>'));
|
||||
|
||||
if (response.dependencies && response.dependencies.length > 0) {
|
||||
this.addDependenciesToList(response.dependencies, slug);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static addNeededDependencyToList(action, slug) {
|
||||
$('.install-dependencies-package-container .type-' + action).removeClass('hidden');
|
||||
let list = $('.install-dependencies-package-container .type-' + action + ' ul');
|
||||
|
||||
if (action !== 'install') {
|
||||
let current_version = '';
|
||||
let available_version = '';
|
||||
let name = '';
|
||||
|
||||
let resources = gpm.payload.payload.resources;
|
||||
|
||||
if (resources.plugins[slug]) {
|
||||
available_version = resources.plugins[slug].available;
|
||||
current_version = resources.plugins[slug].version;
|
||||
name = resources.plugins[slug].name;
|
||||
} else if (resources.themes[slug]) {
|
||||
available_version = resources.themes[slug].available;
|
||||
current_version = resources.themes[slug].version;
|
||||
name = resources.themes[slug].name;
|
||||
}
|
||||
|
||||
list.append(`<li>${name ? name : slug}, ${translations.PLUGIN_ADMIN.FROM} v<strong>${current_version}</strong> ${translations.PLUGIN_ADMIN.TO} v<strong>${available_version}</strong></li>`);
|
||||
} else {
|
||||
list.append(`<li>${name ? name : slug}</li>`);
|
||||
}
|
||||
}
|
||||
|
||||
getPackagesDependencies(type, slugs, finishedLoadingCallback) {
|
||||
let url = Packages.getGetPackagesDependenciesUrl(type);
|
||||
|
||||
request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
packages: slugs
|
||||
}
|
||||
}, (response) => {
|
||||
|
||||
finishedLoadingCallback();
|
||||
|
||||
if (response.status === 'success') {
|
||||
if (response.dependencies) {
|
||||
let hasDependencies = false;
|
||||
for (var dependency in response.dependencies) {
|
||||
if (response.dependencies.hasOwnProperty(dependency)) {
|
||||
if (dependency === 'grav') {
|
||||
continue;
|
||||
}
|
||||
hasDependencies = true;
|
||||
let dependencyName = dependency;
|
||||
let action = response.dependencies[dependency];
|
||||
|
||||
Packages.addNeededDependencyToList(action, dependencyName);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDependencies) {
|
||||
$('[data-packages-modal] .install-dependencies-package-container').removeClass('hidden');
|
||||
} else {
|
||||
$('[data-packages-modal] .install-package-container').removeClass('hidden');
|
||||
}
|
||||
} else {
|
||||
$('[data-packages-modal] .install-package-container').removeClass('hidden');
|
||||
}
|
||||
} else {
|
||||
$('[data-packages-modal] .install-package-error').removeClass('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installDependenciesOfPackages(type, slugs, callbackSuccess, callbackError) {
|
||||
let url = Packages.getInstallDependenciesOfPackagesUrl(type);
|
||||
|
||||
request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
packages: slugs
|
||||
}
|
||||
}, callbackSuccess);
|
||||
}
|
||||
|
||||
installPackages(type, slugs, callbackSuccess) {
|
||||
let url = Packages.getInstallPackageUrl(type);
|
||||
|
||||
Promise.all(slugs.map((slug) => {
|
||||
return request(url, {
|
||||
method: 'post',
|
||||
body: {
|
||||
package: slug,
|
||||
type: type
|
||||
}
|
||||
});
|
||||
})).then(callbackSuccess);
|
||||
|
||||
}
|
||||
|
||||
static getSlugsFromEvent(event) {
|
||||
let slugs = '';
|
||||
if ($(event.target).is('[data-packages-slugs]')) {
|
||||
slugs = $(event.target).attr('data-packages-slugs');
|
||||
} else {
|
||||
slugs = $(event.target).parent('[data-packages-slugs]').attr('data-packages-slugs');
|
||||
}
|
||||
|
||||
if (typeof slugs === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
slugs = slugs.split(',');
|
||||
return typeof slugs === 'string' ? [slugs] : slugs;
|
||||
}
|
||||
|
||||
handleGettingPackageDependencies(type, event, action = 'update') {
|
||||
let slugs = Packages.getSlugsFromEvent(event);
|
||||
|
||||
if (!slugs) {
|
||||
alert('No slug set');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
$('.packages-names-list').html('');
|
||||
$('.install-dependencies-package-container li').remove();
|
||||
|
||||
slugs.forEach((slug) => {
|
||||
if (action === 'update') {
|
||||
let current_version = '';
|
||||
let available_version = '';
|
||||
let name = '';
|
||||
|
||||
let resources = gpm.payload.payload.resources;
|
||||
|
||||
if (resources.plugins[slug]) {
|
||||
available_version = resources.plugins[slug].available;
|
||||
current_version = resources.plugins[slug].version;
|
||||
name = resources.plugins[slug].name;
|
||||
} else if (resources.themes[slug]) {
|
||||
available_version = resources.themes[slug].available;
|
||||
current_version = resources.themes[slug].version;
|
||||
name = resources.themes[slug].name;
|
||||
}
|
||||
|
||||
$('.packages-names-list').append(`<li>${name ? name : slug}, ${translations.PLUGIN_ADMIN.FROM} v<strong>${current_version}</strong> ${translations.PLUGIN_ADMIN.TO} v<strong>${available_version}</strong></li>`);
|
||||
} else {
|
||||
$('.packages-names-list').append(`<li>${name ? name : slug}</li>`);
|
||||
}
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Restore original state
|
||||
$('[data-packages-modal] .loading').removeClass('hidden');
|
||||
$('[data-packages-modal] .install-dependencies-package-container').addClass('hidden');
|
||||
$('[data-packages-modal] .install-package-container').addClass('hidden');
|
||||
$('[data-packages-modal] .installing-dependencies').addClass('hidden');
|
||||
$('[data-packages-modal] .installing-package').addClass('hidden');
|
||||
$('[data-packages-modal] .installation-complete').addClass('hidden');
|
||||
$('[data-packages-modal] .install-package-error').addClass('hidden');
|
||||
|
||||
this.getPackagesDependencies(type, slugs, () => {
|
||||
let slugs_string = slugs.join();
|
||||
$(`[data-packages-modal] [data-${type}-action="install-dependencies-and-package"]`).attr('data-packages-slugs', slugs_string);
|
||||
$(`[data-packages-modal] [data-${type}-action="install-package"]`).attr('data-packages-slugs', slugs_string);
|
||||
$('[data-packages-modal] .loading').addClass('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
handleInstallingDependenciesAndPackage(type, event) {
|
||||
let slugs = Packages.getSlugsFromEvent(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
$('[data-packages-modal] .install-dependencies-package-container').addClass('hidden');
|
||||
$('[data-packages-modal] .installing-dependencies').removeClass('hidden');
|
||||
|
||||
this.installDependenciesOfPackages(type, slugs, (response) => {
|
||||
$('[data-packages-modal] .installing-dependencies').addClass('hidden');
|
||||
$('[data-packages-modal] .installing-package').removeClass('hidden');
|
||||
this.installPackages(type, slugs, () => {
|
||||
$('[data-packages-modal] .installing-package').addClass('hidden');
|
||||
$('[data-packages-modal] .installation-complete').removeClass('hidden');
|
||||
|
||||
if (response.status === 'error') {
|
||||
let remodal = $.remodal.lookup[$('[data-packages-modal]').data('remodal')];
|
||||
remodal.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (slugs.length === 1) {
|
||||
global.location.href = `${config.base_url_relative}/${type}s/${slugs[0]}`;
|
||||
} else {
|
||||
global.location.href = `${config.base_url_relative}/${type}s`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleInstallingPackage(type, event) {
|
||||
let slugs = Packages.getSlugsFromEvent(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
$('[data-packages-modal] .install-package-container').addClass('hidden');
|
||||
$('[data-packages-modal] .installing-package').removeClass('hidden');
|
||||
|
||||
this.installPackages(type, slugs, (response) => {
|
||||
$('[data-packages-modal] .installing-package').addClass('hidden');
|
||||
$('[data-packages-modal] .installation-complete').removeClass('hidden');
|
||||
|
||||
if (response.status === 'error') {
|
||||
let remodal = $.remodal.lookup[$('[data-packages-modal]').data('remodal')];
|
||||
remodal.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (slugs.length === 1) {
|
||||
global.location.href = `${config.base_url_relative}/${type}s/${slugs[0]}`;
|
||||
} else {
|
||||
global.location.href = `${config.base_url_relative}/${type}s`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleRemovingPackage(type, event) {
|
||||
let slug = $(event.target).attr('data-packages-slugs');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.removePackage(type, slug);
|
||||
}
|
||||
|
||||
handleReinstallPackage(type, event) {
|
||||
let target = $(event.target);
|
||||
let slug = target.attr('data-package-slug');
|
||||
let package_name = target.attr('data-package-name');
|
||||
let current_version = target.attr('data-package-current-version');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.reinstallPackage(type, slug, package_name, current_version);
|
||||
}
|
||||
|
||||
handleRemovingDependency(type, event) {
|
||||
let slug = $(event.target).attr('data-dependency-slug');
|
||||
let button = $(event.target);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.removeDependency(type, slug, button);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Packages();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { parseStatus, parseJSON, userFeedback, userFeedbackError } from './response';
|
||||
import { config } from 'grav-config';
|
||||
|
||||
let raw;
|
||||
let request = function(url, options = {}, callback = () => true) {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
if (options.method && options.method === 'post') {
|
||||
let data = new FormData();
|
||||
|
||||
options.body = Object.assign({ 'admin-nonce': config.admin_nonce }, options.body || {});
|
||||
Object.keys(options.body).map((key) => data.append(key, options.body[key]));
|
||||
options.body = data;
|
||||
}
|
||||
|
||||
options = Object.assign({
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}, options);
|
||||
|
||||
return fetch(url, options)
|
||||
.then((response) => {
|
||||
raw = response;
|
||||
return response;
|
||||
})
|
||||
.then(parseStatus)
|
||||
.then(parseJSON)
|
||||
.then(userFeedback)
|
||||
.then((response) => callback(response, raw))
|
||||
.catch(userFeedbackError);
|
||||
};
|
||||
|
||||
export default request;
|
||||
@@ -0,0 +1,101 @@
|
||||
import $ from 'jquery';
|
||||
import toastr from './toastr';
|
||||
import isOnline from './offline';
|
||||
import { config } from 'grav-config';
|
||||
import trim from 'mout/string/trim';
|
||||
|
||||
let UNLOADING = false;
|
||||
let error = function(response) {
|
||||
let error = new Error(response.statusText || response || '');
|
||||
error.response = response;
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
export function parseStatus(response) {
|
||||
return response;
|
||||
|
||||
/* Whoops can handle JSON responses so we don't need this for now.
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
} else {
|
||||
throw error(response);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export function parseJSON(response) {
|
||||
return response.text().then((text) => {
|
||||
let parsed = text;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
let content = document.createElement('div');
|
||||
content.innerHTML = text;
|
||||
|
||||
let the_error = new Error();
|
||||
the_error.stack = trim(content.innerText);
|
||||
|
||||
throw the_error;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
});
|
||||
}
|
||||
|
||||
export function userFeedback(response) {
|
||||
if (UNLOADING) { return true; }
|
||||
|
||||
let status = response.status || (response.error ? 'error' : '');
|
||||
let message = response.message || (response.error ? response.error.message : null);
|
||||
let settings = response.toastr || null;
|
||||
let backup;
|
||||
|
||||
switch (status) {
|
||||
case 'unauthenticated':
|
||||
document.location.href = config.base_url_relative;
|
||||
throw error('Logged out');
|
||||
case 'unauthorized':
|
||||
status = 'error';
|
||||
message = message || 'Unauthorized.';
|
||||
break;
|
||||
case 'error':
|
||||
status = 'error';
|
||||
message = message || 'Unknown error.';
|
||||
break;
|
||||
case 'success':
|
||||
status = 'success';
|
||||
message = message || '';
|
||||
break;
|
||||
default:
|
||||
status = 'error';
|
||||
message = message || 'Invalid AJAX response.';
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
backup = Object.assign({}, toastr.options);
|
||||
Object.keys(settings).forEach((key) => { toastr.options[key] = settings[key]; });
|
||||
}
|
||||
|
||||
if (message && (isOnline || (!isOnline && status !== 'error'))) {
|
||||
toastr[status === 'success' ? 'success' : 'error'](message);
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
toastr.options = backup;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function userFeedbackError(error) {
|
||||
if (UNLOADING) { return true; }
|
||||
let stack = error.stack ? `<pre><code>${error.stack}</code></pre>` : '';
|
||||
toastr.error(`Fetch Failed: <br /> ${error.message} ${stack}`);
|
||||
console.error(`${error.message} at ${error.stack}`);
|
||||
}
|
||||
|
||||
$(global).on('beforeunload._ajax', () => {
|
||||
UNLOADING = true;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import $ from 'jquery';
|
||||
import GeminiScrollbar from 'gemini-scrollbar';
|
||||
|
||||
const defaults = {
|
||||
autoshow: false,
|
||||
createElements: true,
|
||||
forceGemini: false
|
||||
};
|
||||
|
||||
export default class Scrollbar {
|
||||
constructor(element, options) {
|
||||
this.element = $(element);
|
||||
this.created = false;
|
||||
if (!this.element.length) { return; }
|
||||
|
||||
this.options = Object.assign({}, defaults, options, { element: this.element[0] });
|
||||
|
||||
this.element.css('overflow', 'auto');
|
||||
this.instance = new GeminiScrollbar(this.options);
|
||||
this.create();
|
||||
this.element.data('scrollbar', this.instance);
|
||||
}
|
||||
|
||||
create() {
|
||||
this.instance.create();
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.created) { return false; }
|
||||
this.instance.update();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.created) { return false; }
|
||||
this.instance.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export let Instance = new Scrollbar('#admin-main .content-wrapper');
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* This is a plugin to override the `.refreshValidityState` method of
|
||||
* the Selectize library (https://selectize.github.io/selectize.js/).
|
||||
* The library is not maintained anymore (as of 2017-09-13) and contains
|
||||
* a bug which causes Microsoft Edge to not work with selectized [required]
|
||||
* form fields. This plugin should be removed if
|
||||
* https://github.com/selectize/selectize.js/pull/1320 is ever merged
|
||||
* and a new version of Selectize gets released.
|
||||
*/
|
||||
|
||||
import Selectize from 'selectize';
|
||||
|
||||
Selectize.define('required-fix', function(options) {
|
||||
this.refreshValidityState = () => {
|
||||
if (!this.isRequired) return false;
|
||||
|
||||
let invalid = !this.items.length;
|
||||
this.isInvalid = invalid;
|
||||
|
||||
if (invalid) {
|
||||
this.$control_input.attr('required', '');
|
||||
this.$input.removeAttr('required');
|
||||
} else {
|
||||
this.$control_input.removeAttr('required');
|
||||
this.$input.attr('required');
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import $ from 'jquery';
|
||||
import Scrollbar from './scrollbar';
|
||||
import Map from 'es6-map';
|
||||
|
||||
const MOBILE_BREAKPOINT = 48 - 0.062;
|
||||
const DESKTOP_BREAKPOINT = 75 + 0.063;
|
||||
const EVENTS = 'touchstart._grav click._grav';
|
||||
const TARGETS = '[data-sidebar-mobile-toggle], #overlay';
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT}em)`;
|
||||
const DESKTOP_QUERY = `(min-width: ${DESKTOP_BREAKPOINT}em)`;
|
||||
|
||||
let map = new Map();
|
||||
|
||||
export default class Sidebar {
|
||||
constructor() {
|
||||
this.timeout = null;
|
||||
this.isOpen = false;
|
||||
this.body = $('body');
|
||||
this.matchMedia = global.matchMedia(MOBILE_QUERY);
|
||||
this.scroller = new Scrollbar('.admin-menu-wrapper', { autoshow: true });
|
||||
this.enable();
|
||||
}
|
||||
|
||||
enable() {
|
||||
const sidebar = $('#admin-sidebar');
|
||||
|
||||
this.matchMedia.addListener(this._getBound('checkMatch'));
|
||||
this.checkMatch(this.matchMedia);
|
||||
this.body.on(EVENTS, '[data-sidebar-toggle]', this._getBound('toggleSidebarState'));
|
||||
|
||||
if (sidebar.data('quickopen')) {
|
||||
sidebar.hover(this._getBound('quickOpenIn'), this._getBound('quickOpenOut'));
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
const sidebar = $('#admin-sidebar');
|
||||
|
||||
this.close();
|
||||
this.matchMedia.removeListener(this._getBound('checkMatch'));
|
||||
this.body.off(EVENTS, '[data-sidebar-toggle]', this._getBound('toggleSidebarState'));
|
||||
if (sidebar.data('quickopen')) {
|
||||
sidebar.off('mouseenter mouseleave');
|
||||
}
|
||||
}
|
||||
|
||||
attach() {
|
||||
this.body.on(EVENTS, TARGETS, this._getBound('toggle'));
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.body.off(EVENTS, TARGETS, this._getBound('toggle'));
|
||||
}
|
||||
|
||||
quickOpenIn(/* event */) {
|
||||
let isDesktop = global.matchMedia(DESKTOP_QUERY).matches;
|
||||
let delay = $('#admin-sidebar').data('quickopen-delay') || 500;
|
||||
if (this.body.hasClass('sidebar-mobile-open')) { return; }
|
||||
|
||||
let shouldQuickOpen = isDesktop ? this.body.hasClass('sidebar-closed') : !this.body.hasClass('sidebar-open');
|
||||
if (!shouldQuickOpen && !this.body.hasClass('sidebar-quickopen')) { return this.quickOpenOut(); }
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.body.addClass('sidebar-open sidebar-quickopen');
|
||||
$(global).trigger('sidebar_state._grav', isDesktop);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
quickOpenOut(/* event */) {
|
||||
clearTimeout(this.timeout);
|
||||
if (this.body.hasClass('sidebar-quickopen')) {
|
||||
this.body.removeClass('sidebar-open sidebar-quickopen');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
open(event) {
|
||||
if (event) { event.preventDefault(); }
|
||||
let overlay = $('#overlay');
|
||||
let sidebar = $('#admin-sidebar');
|
||||
let scrollbar = $('#admin-menu').data('scrollbar');
|
||||
|
||||
this.body.addClass('sidebar-mobile-open');
|
||||
overlay.css('display', 'block');
|
||||
sidebar.css('display', 'block').animate({
|
||||
opacity: 1
|
||||
}, 200, () => { this.isOpen = true; });
|
||||
|
||||
if (scrollbar) { scrollbar.update(); }
|
||||
}
|
||||
|
||||
close(event) {
|
||||
if (event) { event.preventDefault(); }
|
||||
let overlay = $('#overlay');
|
||||
let sidebar = $('#admin-sidebar');
|
||||
let scrollbar = $('#admin-menu').data('scrollbar');
|
||||
|
||||
this.body.removeClass('sidebar-mobile-open');
|
||||
overlay.css('display', 'none');
|
||||
sidebar.animate({
|
||||
opacity: 0
|
||||
}, 200, () => {
|
||||
sidebar.css('display', 'none');
|
||||
this.isOpen = false;
|
||||
});
|
||||
|
||||
if (scrollbar) { scrollbar.update(); }
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
if (event) { event.preventDefault(); }
|
||||
return this[this.isOpen ? 'close' : 'open'](event);
|
||||
}
|
||||
|
||||
toggleSidebarState(event) {
|
||||
if (event) { event.preventDefault(); }
|
||||
clearTimeout(this.timeout);
|
||||
let isDesktop = global.matchMedia(DESKTOP_QUERY).matches;
|
||||
|
||||
if (isDesktop) {
|
||||
this.body.removeClass('sidebar-open');
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
this.body.removeClass('sidebar-closed');
|
||||
this.body.removeClass('sidebar-mobile-open');
|
||||
}
|
||||
|
||||
this.body.toggleClass(`sidebar-${isDesktop ? 'closed' : 'open'}`);
|
||||
$(global).trigger('sidebar_state._grav', isDesktop);
|
||||
}
|
||||
|
||||
checkMatch(data) {
|
||||
let sidebar = $('#admin-sidebar');
|
||||
let overlay = $('#overlay');
|
||||
this.isOpen = false;
|
||||
|
||||
overlay.css('display', 'none');
|
||||
sidebar.css({
|
||||
display: data.matches ? 'none' : 'inherit',
|
||||
opacity: data.matches ? 0 : 1
|
||||
});
|
||||
|
||||
if (data.matches) {
|
||||
this.body.removeClass('sidebar-open sidebar-closed');
|
||||
}
|
||||
|
||||
this[data.matches ? 'attach' : 'detach']();
|
||||
}
|
||||
|
||||
_getBound(fn) {
|
||||
if (map.has(fn)) {
|
||||
return map.get(fn);
|
||||
}
|
||||
|
||||
return map.set(fn, this[fn].bind(this)).get(fn);
|
||||
}
|
||||
}
|
||||
|
||||
export let Instance = new Sidebar();
|
||||
@@ -0,0 +1,41 @@
|
||||
// localStorage
|
||||
(function() {
|
||||
function isSupported() {
|
||||
var item = 'localStoragePollyfill';
|
||||
try {
|
||||
localStorage.setItem(item, item);
|
||||
localStorage.removeItem(item);
|
||||
sessionStorage.setItem(item, item);
|
||||
sessionStorage.removeItem(item);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSupported()) {
|
||||
try {
|
||||
Storage.prototype._data = {};
|
||||
|
||||
Storage.prototype.setItem = function(id, val) {
|
||||
this._data[id] = String(val);
|
||||
return this._data[id];
|
||||
};
|
||||
|
||||
Storage.prototype.getItem = function(id) {
|
||||
return this._data.hasOwnProperty(id) ? this._data[id] : undefined;
|
||||
};
|
||||
|
||||
Storage.prototype.removeItem = function(id) {
|
||||
return delete this._data[id];
|
||||
};
|
||||
|
||||
Storage.prototype.clear = function() {
|
||||
this._data = {};
|
||||
return this._data;
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('localStorage pollyfill error: ', e);
|
||||
}
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,29 @@
|
||||
import $ from 'jquery';
|
||||
import Cookies from 'cookies-js';
|
||||
import { Instance as Editors } from '../forms/fields/editor';
|
||||
|
||||
let Data = JSON.parse(Cookies.get('grav-tabs-state') || '{}');
|
||||
|
||||
$('body').on('touchstart click', '[data-tabid]', (event) => {
|
||||
event && event.stopPropagation();
|
||||
let target = $(event.currentTarget);
|
||||
|
||||
Data[target.data('tabkey')] = target.data('scope');
|
||||
Cookies.set('grav-tabs-state', JSON.stringify(Data), { expires: Infinity });
|
||||
|
||||
const panel = $(`[id="${target.data('tabid')}"]`);
|
||||
|
||||
target.siblings('[data-tabid]').removeClass('active');
|
||||
target.addClass('active');
|
||||
|
||||
panel.siblings('[id]').removeClass('active');
|
||||
panel.addClass('active');
|
||||
|
||||
Editors.editors.each((index, editor) => {
|
||||
let codemirror = $(editor).data('codemirror');
|
||||
if (!codemirror) { return; }
|
||||
if (codemirror.display.lastWrapWidth === 0) {
|
||||
codemirror.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import toastr from 'toastr';
|
||||
|
||||
toastr.options.positionClass = 'toast-top-right';
|
||||
toastr.options.preventDuplicates = true;
|
||||
|
||||
export default toastr;
|
||||
Reference in New Issue
Block a user