// ==UserScript==
// @name AuthorTodayBlackList
// @name:ru AuthorTodayBlackList
// @namespace 90h.yy.zz
// @version 0.14.0
// @author Ox90
// @match https://author.today/*
// @description The script implements the black list of authors on the author.today website.
// @description:ru Скрипт реализует черный список авторов на сайте author.today.
// @run-at document-start
// @license MIT
// ==/UserScript==
/**
* TODO list
* - Поменять иконку черного списка в профиле автора на что-нибудь более подходящее и заметное
* - Добавить возможность скрытия книг автора в виджетах, если скрытие возможно
* - Адаптация к мобильной версии сайта
*/
(function start() {
"use strict";
/**
* Старт скрипта сразу после загрузки DOM-дерева.
*
* Тут настраиваются стили, инициируется объект для текущей страницы,
* устанавливается отслеживание измерений страницы скриптами сайта
* и настраивается перехват кликов по плашкам.
*
* @return void
*/
function start() {
addStyle("body.atbl-debug .atbl-handled { border:1px solid red !important; }"); // Для целей отладки
addStyle(".atbl-badge { position:absolute; display:flex; align-items:center; justify-content:center; bottom:10px; right:10px; width:58px; height:58px; text-align:center; border:4px solid #333; border-radius:50%; background:#aaa; box-shadow:0 0 8px white; z-index:3; }");
addStyle(".atbl-badge span { display:inline-block; color:#400; font:24px Roboto,tahoma,sans-serif; font-weight:bold; }");
addStyle(".atbl-profile-notes { color:#fff; font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:1px 1px 3px rgba(0,0,0,.8); }");
addStyle(".atbl-profile-notes i { margin-right:.5em; }");
addStyle(".atbl-book-banner { position:absolute; bottom:0; left:0; right:0; height:30%; color:#fff; font-weight:bold; background-color:rgba(40,40,40,.95); border:2px ridge #666; z-index:100; }");
addStyle(".atbl-fence-block { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; z-index:99; cursor:pointer; transition:all 500ms cubic-bezier(.7,0,0.08,1); }");
addStyle(".atbl-fence-block .atbl-note { display:flex; min-height:30%; padding:.5em; color:#fff; font-weight:bold; font-size:150%; align-items:center; justify-content:center; background-color:#282828; border:2px ridge #666; opacity:.92 }");
addStyle(".atbl-book-banner, .atbl-fence-block, .atbl-button-container, .atbl-titled-element { display:flex; align-items:center; justify-content:center; }");
addStyle(".book-row>.atbl-fence-block.atbl-open, tr>.atbl-fence-block.atbl-open, .profile-card>.atbl-fence-block.atbl-open { left:95%; }");
addStyle(".bookcard>.atbl-fence-block.atbl-open { top:-85%; }");
addStyle(".bookcard.atbl-marked { overflow-y:hidden; }");
addStyle(".profile-card.atbl-marked, .collection-work-list, aside-widget>.panel { overflow-x:clip; }");
addStyle("tr.atbl-marked { position:relative; }");
addStyle(".book-row>.atbl-fence-block .atbl-note { min-width:30%; }");
addStyle(".atbl-marked .ribbon, .atbl-marked .bookcard-discount { display:none; }");
addStyle(".slick-list>.slick-track { display:flex; }");
addStyle(".slick-list>.slick-track .bookcard, book { height:auto; }");
addStyle(".book-shelf.book-row { align-items:normal; }");
addStyle(".atbl-tab-content { display:none; margin-top:15px; }");
addStyle(".atbl-tab-content.active { display:block; }");
addStyle(".atbl-table { display:table; empty-cells:show; margin:15px 0; width:100%; border:1px solid #ddd; border-radius:4px; }");
addStyle(".atbl-table-header { display:table-header-group; font-weight:bold; background-color:#eee; }");
addStyle(".atbl-table-body { display:table-row-group; }");
addStyle(".atbl-table-row { display:table-row; }");
addStyle(".atbl-table-body .atbl-table-row { cursor:pointer; }");
addStyle(".atbl-table-body .atbl-table-row:hover { background-color:#eef; }");
addStyle(".atbl-table-cell { display:table-cell; padding:.3em .5em; }");
addStyle(".atbl-table-cell:not(:last-child) { border-right:1px solid #ddd; }");
addStyle(".atbl-table-body .atbl-table-cell { border-top:1px solid #ddd; }");
addStyle(".atbl-tab-content p { margin:0; }");
addStyle(".atbl-tab-content legend { font-size:130%; }");
addStyle(".atbl-tab-content fieldset form { display:flex; flex-direction:column; row-gap:1em; align-items:start; }");
addStyle(".atbl-button-container { column-gap:16px; margin-top:24px; }");
addStyle(".atbl-button-container button { min-width:8em; }");
addStyle("#search-results .book-row.atbl-marked .atbl-fence-block { top:-10px; }");
addStyle(".collection-work-list .book-row .atbl-fence-block { top:-14px; height:unset; bottom:6px; }");
addStyle(".work-widget-list .book-row .atbl-fence-block { top:-6px; }");
addStyle(".atbl-titled-element { column-gap:1em; }");
addStyle(".atbl-settings-row { display:flex; column-gap:2em; flex-wrap:wrap; }");
let page = null;
function getPageName() {
const path = document.location.pathname;
if (path === "/" || path === "/what-to-listen-to") return "main";
if (path === "/search") return "search";
if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) return "account";
if (path.startsWith("/u/")) {
if ([
"/library", "/library/reading", "/library/saved", "/library/finished", "/library/", "/library/all",
"/library/", "/library/marks", "/library/purchased", "/library/disliked"
].some(ps => path.endsWith(ps))) return "library";
return "profile";
}
if (path.startsWith("/work/genre/") || path.startsWith("/work/recommended/") || path.startsWith("/work/discounts/")) return "categories";
if (path.startsWith("/top/writers") || path.startsWith("/top/users")) return "users";
if (path.startsWith("/collection/")) return "collection";
if ([ "/contests", "/work/", "/review/", "/post/" ].some(ps => path.startsWith(ps))) return "general";
return "unknown";
}
function updatePageInstance() {
const pname = getPageName();
if (!page || page.name !== pname) page = Page.byName(pname);
page && page.update();
}
function tuneAjaxContainer() {
// Отслеживание изменения главного контейнера страницы сайта
// на случай обновления страницы через AJAX запрос.
// Все потомки не отслеживаются, только изменение списка детей.
let ajax_box = document.getElementById("pjax-container");
if (ajax_box) {
(new MutationObserver(function() {
updatePageInstance();
})).observe(ajax_box, { childList: true });
// Скрытие плашки по клику
ajax_box.addEventListener("click", function(event) {
let fence = event.target.closest(".atbl-fence-block");
fence && fence.classList.toggle("atbl-open");
});
}
}
function updateFenceColors(color1, color2, opacity1, opacity2) {
// Удалить старую запись стилей
const element_id = "atbl-fence_styles";
const css_el = document.getElementById(element_id);
css_el && css_el.remove();
// Создать новые стили для плашки
let c1 = hexToRgb(color1);
let c2 = hexToRgb(color2);
let o1 = parseFloat(opacity1) || 0;
let o2 = parseFloat(opacity2) || 0;
let selector = ".atbl-fence-block";
addStyle(
`${selector} { background-image:repeating-linear-gradient(-45deg,rgba(${c1},${o1}) 0 10px,rgba(${c2},${o2}) 10px 20px); }`,
element_id
);
}
// Установка наблюдателя за изменением настроек оформления
(new BroadcastChannel("colors-changed")).addEventListener("message", (message) => {
updateFenceColors(message.data[0], message.data[1], message.data[2], message.data[3]);
});
// Запрос основных настроек скрипта
(async () => {
// Обрамление обработанных блоков на странице
if (await DB.getSetting("debug.handled", false)) {
document.body.classList.add("atbl-debug");
}
// Цвета плашки
const color1 = await DB.getSetting("book.fence.color1", "#000000");
const color2 = await DB.getSetting("book.fence.color2", "#000000");
const opacity1 = await DB.getSetting("book.fence.opacity1", .3);
const opacity2 = await DB.getSetting("book.fence.opacity2", .2);
updateFenceColors(color1, color2, opacity1, opacity2);
// Идентификация и обновление страницы
updatePageInstance();
tuneAjaxContainer();
})();
}
//----------------------------
//---------- Классы ----------
//----------------------------
/**
* Класс общего назначения, для создания общих HTML-элементов
*/
class HTML {
/**
* Создает единичный элемент типа checkbox со стилями сайта
*
* @param title string Подпись для checkbox
* @param name string Значение атрибута name у checkbox
* @param checked bool Начальное состояние checkbox
*
* @return Element HTML-элемент для последующего добавления на форму
*/
static createCheckbox(title, name, checked) {
let root = document.createElement("div");
root.classList.add("checkbox", "c-checkbox", "no-fastclick");
let label = document.createElement("label");
root.appendChild(label);
let input = HTML.createInputElement("checkbox", name);
checked && (input.checked = true);
label.appendChild(input);
let span = document.createElement("span");
span.classList.add("icon-check-bold");
label.appendChild(span);
label.appendChild(document.createTextNode(title));
return root;
}
/**
* Создает единичный элемент select с опциями для выбора со стилями сайта
*
* @param name string Имя элемента для его идентификации в DOM-дереве
* @param options array Массив объектов с параметрами value и text
* @param value string Начальное значение выбранной опции
*
* @return Element HTML-элемент для последующего добавления на форму
*/
static createSelectbox(name, options, value) {
let el = document.createElement("select");
el.classList.add("form-control");
el.name = name;
options.forEach(function(it) {
let oel = document.createElement("option");
oel.value = it.value;
oel.textContent = it.text;
el.appendChild(oel);
});
el.value = value;
return el;
}
/**
* Создает кнопку submit с заданными свойствами со стилями сайта
*
* @param name string Имя кнопки
* @param text string Текст кнопки
* @param type string Тип кнопки
*
* @return Element HTML-элемент кнопки
*/
static createSubmitButton(name, text, type) {
if (!type) type = "default";
let btn = document.createElement("button");
btn.type = "submit";
btn.name = name;
btn.classList.add("btn", "btn-" + type);
btn.textContent = text;
return btn;
}
/**
* Создает элемент для выбора цвета
*
* @param title string Надпись рядом с элементом
* @param name string Имя элемента
* @param value string Предустановленный цвет в формате #xxyyzz
*
* @return Element HTML-элемент
*/
static createColorPicker(title, name, value) {
return HTML.createTitledElement(HTML.createInputElement("color", name, value), title);
}
/**
* Создает элемент выбора значения из диапазона
*
* @param title string Надпись рядом с элементом
* @param name string Имя элемента
* @param value nubmer Текущее значение
* @param min number Минимальное допустимое значение
* @param max number Максимальное допустимое значение
*
* @return Element HTML-элемент
*/
static createRangeElement(title, name, value, min, max) {
const input = HTML.createInputElement("range", name, value);
input.min = min;
input.max = max;
return HTML.createTitledElement(input, title);
}
/**
* Создает стандартный элемент input с указанными параметрами
*
* @param type string Тип элемента
* @param name string Имя элемента
* @param value mixed Текущее значение элемента (не обязательно)
*
* @return Element HTML-элемент
*/
static createInputElement(type, name, value) {
const input = document.createElement("input");
input.id = name;
input.type = type;
input.name = name;
if (value !== undefined) input.value = value;
return input;
}
/**
* Добавляет переданному элементу текстовую надпись и возвращает новый элемент
*
* @param element Element HTML-элемент
* @param title string Надпись для элемента
*
* @return Element HTML-элемент
*/
static createTitledElement(element, title) {
const div = document.createElement("div");
div.classList.add("atbl-titled-element");
div.appendChild(element);
const label = document.createElement("label");
label.setAttribute("for", element.id);
label.textContent = title;
div.appendChild(label);
return div;
}
}
/**
* Экземпляр класса для работы с базой данных браузера (IndexedDB)
* Все методы класса работают в асинхронном режиме
*/
let DB = new class {
constructor() {
this._dbh = null;
}
/**
* Получение общего количества записей о пользователях
*
* @return Promise Промис с количеством записей
*/
usersCount() {
return new Promise(function(resolve, reject) {
this._ensureOpen().then(function(dbh) {
let req = dbh.transaction("users", "readonly").objectStore("users").count();
req.onsuccess = function() {
resolve(req.result);
}
req.onerror = function() {
reject(req.error);
}
}).catch(function(err) {
reject(err);
});
}.bind(this));
}
/**
* Получение данных о пользователе по его nick, если он сохранен в базе данных
*
* @param user User Экземпляр класса пользователя, по которому необходимо сделать запрос
*
* @return Promise Промис с данными пользователя или undefined в случае отсутствия его в базе
*/
fetchUser(user) {
return new Promise(function(resolve, reject) {
this._ensureOpen().then(function(dbh) {
let req = dbh.transaction("users", "readonly").objectStore("users").get(user.nick);
req.onsuccess = function() {
resolve(req.result);
}
req.onerror = function() {
reject(req.error);
}
}).catch(function(err) {
reject(err);
});
}.bind(this));
}
/**
* Сохранение данных пользователя в базе данных. Если запись не существует, она будет добавлена.
* Ключом является nick пользователя
*
* @param user User Экземпляр класса пользователя, данные которого нужно сохранить
*
* @return Promise Промис, который не возвращает никаких данных, но гарантирующий, что данные сохранены
*/
updateUser(user) {
return new Promise(function(resolve, reject) {
this._ensureOpen().then(function(dbh) {
let ct = new Date();
let req = dbh.transaction("users", "readwrite").objectStore("users").put({
nick: user.nick,
fio: user.fio,
notes: user.notes,
b_action: user.b_action,
lastUpdate: ct
});
req.onsuccess = function() {
user.lastUpdate = ct;
resolve();
};
req.onerror = function() {
reject(req.error);
};
}).catch(function(err) {
reject(err);
});
}.bind(this));
}
/**
* Удаляет запись пользователя из базы данных. Ключом является nick пользователя
*
* @param user User Экземпляр класса пользователя, которого нужно удалить
*
* @return Promise Промис, который не возвращает никаких данных, но гарантирующий, что запись удалена
*/
deleteUser(user) {
return new Promise(function(resolve, reject) {
this._ensureOpen().then(function(dbh) {
let req = dbh.transaction("users", "readwrite").objectStore("users").delete(user.nick);
req.onsuccess = function() {
resolve();
};
req.onerror = function() {
reject.req(req.error);
};
}).catch(function(err) {
reject(err);
});
}.bind(this));
}
/**
* Возвращает список пользователей из базы данных
*
* @param count number Максимальное количество записей. По умолчанию: 20. Отрицательное - без ограничений
* @param offset number Сколько записей нужно пропустить перед началом выборки. По умолчанию: 0
*
* @return Promise Промис результатом
*/
usersList(count, offset) {
return new Promise(function(resolve, reject) {
this._ensureOpen().then(function(dbh) {
let res = [];
let offs = 0;
let cursor = null;
if (!count) count = 20;
let req = dbh.transaction("users", "readonly").objectStore("users").openCursor();
req.addEventListener("success", event => {
let cursor = event.target.result;
if (!cursor) {
resolve(res);
return;
}
if (offset) {
offs = offset;
offset = 0;
} else {
res.push(cursor.value);
if (!(--count)) {
resolve(res);
return;
}
}
if (offs) cursor.advance(offs);
else cursor.continue();
offs = 0;
});
req.addEventListener("error", err => {
reject(err);
});
}).catch(function(err) {
reject(err);
});
}.bind(this));
}
/**
* Читает значение настроек из базы данных
*
* @param key string Имя настройки
* @param defval mixed Будет возвращено, если запись отсутствует
*
* @return Promise Промис со значением
*/
getSetting(key, defval) {
return new Promise((resolve, reject) => {
this._ensureOpen().then(dbh => {
let req = dbh.transaction("settings", "readonly").objectStore("settings").get(key);
req.addEventListener("success", event => {
let res = event.target.result;
resolve(res !== undefined ? res : defval);
});
req.addEventListener("error", err => reject(err));
}).catch(err => {
reject(err);
});
});
}
/**
* Сохраняет настройку в базе данных
*
* @param key string Имя настройки
* @param value mixed Значение настройки
*
* @return Promise Промис что все сохранено
*/
updateSetting(key, value) {
return new Promise((resolve, reject) => {
this._ensureOpen().then(dbh => {
let req = dbh.transaction("settings", "readwrite").objectStore("settings").put(value, key);
req.addEventListener("success", event => resolve());
req.addEventListener("error", err => resolve(err));
}).catch(err => {
reject(err);
});
});
}
/**
* Гарантирует соединение с базой данных
*
* @return Promise Промис, который возвращает объект для работы с базой данных
*/
_ensureOpen() {
return new Promise(function(resolve, reject) {
if (this._dbh) {
resolve(this._dbh);
return;
}
let req = indexedDB.open("atbl_main_db", 2);
req.onsuccess = function() {
this._dbh = req.result;
resolve(this._dbh);
}.bind(this);
req.onerror = function() {
reject(req.error);
};
req.onupgradeneeded = function(event) {
let db = req.result;
if (!db.objectStoreNames.contains("users")) {
db.createObjectStore("users", { keyPath: "nick" });
}
if (!db.objectStoreNames.contains("settings")) {
db.createObjectStore("settings");
}
};
req.onblocked = function() {
Notification.display("Обновление базы данных скрипта блокируется другой вкладкой", "warning");
};
}.bind(this));
}
}();
/**
* Класс для работы с данными автора или пользователя.
*/
class User {
/**
* Конструктор класса
*
* @param nick string Ник пользователя для идентификации
* @param fio string Фамилия, имя пользователя. Или что там записано. Не обязательно.
*
* @return void
*/
constructor(nick, fio) {
this.nick = nick;
this.fio = fio || "";
this.empty = true;
this._requests = [];
this._init();
}
/**
* Обновляет данные пользователя из базы данных
*
* @return Promise Промис, гарантирует обновление полей пользователя
*/
fetch() {
if (!this._requests.length) {
return DB.fetchUser(this).then(function(res) {
if (res) {
this._fillData(res);
} else {
this._init();
}
this._requests.forEach(req => req.resolve());
this._requests = [];
}.bind(this)).catch(function(err) {
this._requests.forEach(req =>req.reject(err));
this._requests = [];
throw err;
}.bind(this));
}
return new Promise(function(resolve, reject) {
this._requests.push({ resolve: resolve, reject: reject });
}.bind(this));
}
/**
* Сохраняет текущие данные пользователя в базу данных
*
* @return Promise Промис, гарантирует обновление данных
*/
async save() {
await DB.updateUser(this);
this.empty = false;
}
/**
* Удаляет пользователя из базы данных
*
* @return Promise Промис, гарантирующий удаление данных пользователя
*/
async delete() {
await DB.deleteUser(this);
this._init();
}
/**
* Возвращает первую строчку заметки с вырезанными пробельными символами слева и справа
*
* @param note string Оригинальная строка заметки
*
* @return string
*/
shortNote() {
if (!this.notes || !this.notes.text) return;
let ntxt = this.notes.text;
let eoli = ntxt.indexOf("\n");
if (eoli !== -1) ntxt = ntxt.substring(0, eoli).trim();
return ntxt;
}
/**
* Создает экземпляр класса User по переданным данным
*
* @param data object Данные пользователя
*
* @return User
*/
static fromObject(data) {
let usr = new User(data.nick);
usr._fillData(data);
return usr;
}
/**
* Инициализация свойств начальными значениями
*
* @return void
*/
_init() {
this.notes = null;
this.b_action = null;
this.lastUpdate = null;
this.empty = true;
}
/**
* Заполняет экземпляр с переданных данных
*
* @param data object Объект с данными, обычно из БД
*
* @return void
*/
_fillData(data) {
this.notes = data.notes || {};
this.lastUpdate = data.lastUpdate;
this.b_action = data.b_action;
if (!this.fio) this.fio = data.fio;
this.empty = false;
}
}
/**
* Класс реализует список пользователей
*/
class UserList extends Array {
/**
* Получает список пользователей из базы данных и возвращает экземпляр списка
*
* @param count number Максимальное количество записей. Необязательное значение. По умолчанию: 20.
* @param offset number Смещение начала выборки записей. Необязательное значение. По умолчанию: 0.
*
* @return Promise Промис с экземпляром списка пользователей
*/
static async fetch(count, offset) {
let res = new UserList();
await DB.usersList(count, offset).then(list => {
list.forEach(d => res.push(User.fromObject(d)));
});
return res;
}
}
/**
* Класс для работы со списком пользователей в режиме кэша.
* Предназначен для того, чтобы избежать дублирование запросов к базе данных.
* Расширяет стандартный класс Map.
*/
class UserCache extends Map {
/**
* Асинхронный метод для получения гарантии наличия пользователей в кэше, которые, при необходимости, загружаются из БД
*
* @param ids array Массив идентификаторов пользователей (nick) для которых необходимы данные
*
* @return Promise Промис, гарантирующий, что все данные о переданных пользователях находятся в кэше
*/
async ensure(ids) {
let p_list = ids.reduce(function(res, id) {
if (!this.has(id)) {
let user = new User(id);
this.set(id, user);
res.push(user.fetch());
}
return res;
}.bind(this), []);
if (p_list.length) {
await Promise.all(p_list);
}
}
}
/**
* Базовый класс манипуляций с виджетами страниц сайта
*/
class Page {
constructor() {
this.name = null;
this.users = new UserCache();
this._widgets = [];
this._channel = new BroadcastChannel("user-updated");
this._channel.onmessage = event => {
this._widgets.forEach(w => w.userUpdated(event.data));
this._userUpdated(event.data);
};
}
/**
* Метод для запуска обновлений всех виджетов на странице
*
* @return void
*/
update() {
this._widgets.forEach(w => w.update());
}
/**
* Этот метод будет вызван в случае изменения данных какого-нибудь автора в другой вкладке
* Применяется в контексте страницы
*
* @param nick string Ник обновленного пользователя
*
* @return void
*/
_userUpdated(nick) {
}
/**
* Метод-фабрика для получения экземпляра страницы по ее имени
*
* @param name string Имя страницы
*
* @return Page
*/
static byName(name) {
switch (name) {
case "main":
return new MainPage();
case "account":
return new AccountPage();
case "profile":
return new ProfilePage();
case "categories":
return new CategoriesPage();
case "users":
return new UsersPage();
case "search":
return new SearchPage();
case "collection":
return new CollectionPage();
case "general":
return new GeneralPage();
case "library":
return new LibraryPage();
}
}
}
/**
* Базовый класс для различных информационных блоков сайта типа книжной полки и списка авторов
* На одной странице может присутствовать несколько виджетов
*/
class Widget {
constructor(element) {
this.element = element;
}
/**
* Базовая реализация для обновления виджета
*
* @return void
*/
update() {
}
/**
* Этот метод будет вызван в случае изменения данных какого-нибудь автора в другой вкладке
* Применяется в контексте виджета
*
* @param nick string Ник обновленного пользователя
*
* @return void
*/
userUpdated(nick) {
}
}
/**
* Базовая реализация класса виджета, контейнер которого может быть обновлен отдельно,
* через отдельный AJAX запрос. Обычно такое происходит при смене параметров фильтра.
*/
class AjaxWidget extends Widget {
/**
* Конструктор класса
*
* @param element Element HTML-элемент контейнера виджета
* @param watch bool Нужно ли оставлять наблюдатель после первой загрузки данных
* @param deep bool Нужно ли отслеживать потомков
*
* return void
*/
constructor(element, watch, deep) {
super(element);
this._watch = watch;
this._deep = deep;
this._observed = false;
}
/**
* Во время обновления виджета при необходимости на основной элемент вешается наблюдатель
* Будет ли он висеть постоянно или будет отключен после заполнения виджета,
* зависит от флага watch, переданного в конструктор
*
* @return void
*/
update() {
const ready = this._isReady();
if (ready) {
// Сканировать и обновить панель
this._updatePanel();
super.update();
}
if (!this._observed && (!ready || this._watch)) {
// Установить наблюдатель
this._observed = true;
(new MutationObserver(function(mutations, observer) {
if (this._isReady()) {
if (!this._watch) observer.disconnect();
this._updatePanel();
}
}.bind(this))).observe(this.element, { childList: true, subtree: this._deep });
}
}
/**
* Проверяет готов ли виджет для обновления
*
* @return bool
*/
_isReady() {
return !this.element.querySelector(".overlay");
}
/**
* Код для фактическогое обновления виджета в результате вызова метода update
* или срабатывания наблюдателя в момент завершения заполнения виджета данными
*
* @return void
*/
_updatePanel() {
}
}
/**
* Реализация AJAX виджета-контейнера, который сам содержит виджеты
*/
class AjaxContainer extends AjaxWidget {
/**
* Конструктор класса
*
* @param element Element HTML-элемент контейнера виджета
* @param wparams Array Массив пар виджет-селектор, которые будут отображаться в контейнере
*
* @return void
*/
constructor(element, wparams) {
super(element, true, false);
this._widgets = [];
this._selectors = [];
wparams.forEach(it => {
this._widgets.push(it[0]);
this._selectors.push(it[1]);
});
}
userUpdated(nick) {
this._widgets.forEach(w => w.userUpdated(nick));
}
_isReady() {
return true;
}
_updatePanel() {
this._widgets.forEach((w, i) => {
w.element = this.element.querySelector(this._selectors[i]);
w.element && w.update();
});
}
}
/**
* Отображение обычной книжной полки с учетом раскладки
*/
class BookShelfWidget extends AjaxWidget {
/**
* Конструктор класса
*
* @param element Element HTML-элемент виджета
* @param params object Параметры для управления виджетом
*/
constructor(element, params) {
super(element, params.watch || false, false);
this._users = params.users || new UserCache();
this._layout = params.layout || {};
}
/**
* Извлекает из панели список авторов, проверяет их настройки и обновляет блок с книгами
*
* @return void
*/
_updatePanel() {
this._layout.name = this._getLayout();
if (!this._layout.name) return;
const query = this._layout[this._layout.name];
if (!query) return;
const authors = BookElement.getAuthorList(this.element);
if (!authors.length) return;
this._users.ensure(authors).then(() => {
try {
// Получить элементы книг и обработать их
let books = this.element.querySelectorAll(query + ":not(.atbl-handled)");
if (books.length) {
books.forEach(be => {
let book = this._getBook(be);
switch (this.getBookAction(book)) {
case "mark":
book.mark();
break;
}
book.element.classList.add("atbl-handled");
});
}
} catch(err) {
Notification.display(err.message, "error");
}
});
}
/**
* Этот метод вызывается в случае изменения данных какого-нибудь автора в другой вкладке
*
* @param nick string Ник обновленного пользователя
*
* @return void
*/
userUpdated(nick) {
let user = this._users.get(nick);
if (!user) return;
if (!this._layout.name) return;
const query = this._layout[this._layout.name];
if (!query) return;
user.fetch().then(() => {
this.element.querySelectorAll(query + ".atbl-handled").forEach(be => {
let book = this._getBook(be);
if (!book.hasAuthor(nick)) return;
switch(this.getBookAction(book)) {
case "mark":
book.mark();
break;
case "unmark":
book.unmark();
break;
}
});
});
}
/**
* Возвращает наименование раскладки книжной полки
*
* @return string Одно из следующих значений: 'list', 'grid', 'table' или undefined
*/
_getLayout() {
if (this._layout.selector) {
const ico = (this.element || document).querySelector(this._layout.selector);
if (ico) {
switch (ico.getAttribute("class")) {
case "icon-list":
return "list";
case "icon-grid":
return "grid";
case "icon-bars":
return "table";
}
}
}
if (this._layout.default) return this._layout.default;
}
/**
* Возвращает экземляр класса BookElement с учетом текущей раскладки книжной полки
*
* @param el Element HTML-элемент книги
*
* @return BookElement
*/
_getBook(el) {
switch (this._layout.name) {
case "list":
return new BookRowElement(el);
case "grid":
return new BookCardElement(el);
case "table":
return new BookTableElement(el);
}
return new BookElement(el);
}
/**
* Возвращает строку с идентификатором действия для указанной книги
*
* @param book BookElement Экземпляр класса книги
*
* @return string Возможные значения: 'mark', 'unmark', 'none'
*/
getBookAction(book) {
if (book.authors.length) {
if (book.authors.every(nick => this._users.get(nick).b_action === "mark")) {
return "mark";
}
return "unmark";
}
return "none";
}
}
/**
* Книжная полка со спиннером загрузки.
* Используется на заглавной странице сайта и в боковых виджетах.
*/
class SpinnerBookShelfWidget extends BookShelfWidget {
_isReady() {
return !this.element.querySelector(".widget-spinner");
}
}
/**
* Книжная полка в боковом виджете (рекомендации, скидки и т.п.)
*/
class AsideBookShelfWidget extends SpinnerBookShelfWidget {
constructor(element, params) {
super(element, params);
this._deep = true;
}
_isReady() {
return this.element.children.length && super._isReady() || false;
}
}
/**
* Виджет для отображение значка на аватаре пользователя, если это необходимо
*/
class ProfileAvatarWidget extends Widget {
constructor(element, user) {
super(element);
this.user = user;
this._badge = null;
}
update() {
if (!this.user.b_action || this.user.b_action === "none") {
if (this._badge) {
this._badge.remove();
}
return;
}
if (!this._badge) this._createBadgeElement();
if (!this.element.contains(this._badge)) {
this.element.appendChild(this._badge);
}
}
_createBadgeElement() {
this._badge = document.createElement("div");
this._badge.classList.add("atbl-badge");
let span = document.createElement("span");
span.appendChild(document.createTextNode("ЧС"));
this._badge.appendChild(span);
}
}
/**
* Виджет для отображение заметок в профиле пользователя, если необходимо
*/
class ProfileNotesWidget extends Widget {
constructor(element, user) {
super(element);
this.user = user;
this._notes = null;
}
update() {
if (this.user.notes && this.user.notes.profile && this.user.notes.text) {
let ntxt = this.user.shortNote();
if (!this._notes) {
this._notes = document.createElement("div");
this._notes.classList.add("atbl-profile-notes");
let icon = document.createElement("i");
icon.classList.add("icon-info-circle");
this._notes.appendChild(icon);
let span = document.createElement("span");
span.appendChild(document.createTextNode(ntxt));
this._notes.appendChild(span);
} else {
this._notes.querySelector("span").textContent = ntxt;
}
if (!this.element.contains(this._notes)) {
this.element.appendChild(this._notes);
}
} else if (this._notes) {
this._notes.remove();
}
}
}
/**
* Виджет для добавления пункта меню в профиль пользователя
*/
class ProfileMenuWidget extends Widget {
constructor(element) {
super(element);
this.menuItem = this._createMenuItem();
}
update() {
this.element = document.querySelector("div.cover-buttons>ul.dropdown-menu");
if (this.element && this.element.children.length) {
if (this.menuItem && !this.element.contains(this.menuItem)) {
this.element.appendChild(this.menuItem);
}
}
}
/**
* Создает элемент меню, копируя стиль с первого элемента
*
* @return Element|null
*/
_createMenuItem() {
let item = this.element.children[0].cloneNode(true);
let iitem = item.querySelector("i");
let aitem = item.querySelector("a");
let ccnt = iitem && aitem && aitem.childNodes.length || 0;
if (ccnt < 2) return null;
iitem.setAttribute("class", "icon-pencil mr");
iitem.setAttribute("style", "margin-right:17px !important;");
aitem.removeAttribute("onclick");
aitem.childNodes[ccnt - 1].textContent = "AuthorTodayBlackList (ATBL)";
return item;
}
}
/**
* Виджет с карточкой автора (как на странице с книгой)
*/
class UserCardWidget extends Widget {
/**
* Конструктор
*
* @param element Element HTML-элемент карточки
* @param params Object Параметры виджета
*
* @return void
*/
constructor(element, params) {
super(element);
this._users = params.users || new UserCache();
this._card = new UserAsideElement(element);
}
update() {
let nick = this._card.nick;
if (!nick) return;
this._users.ensure([ nick ]).then(() => {
try {
if (!this.element.classList.contains("atbl-handled")) {
if (this._users.get(nick).b_action === "mark") {
this._card.mark();
}
this.element.classList.add("atbl-handled");
}
} catch (err) {
Notification.display(err.message, "error");
}
});
}
userUpdated(nick) {
if (nick !== this._card.nick || !this.element.classList.contains("atbl-handled")) return;
const user = this._users.get(nick);
if (!user) return;
user.fetch().then(() => {
const card = new UserAsideElement(this.element);
if (user.b_action === "mark") card.mark();
else card.unmark();
});
}
}
/**
* Виджет отображения списка пользователей в виде карточек
*/
class UsersWidget extends AjaxWidget {
constructor(element, params) {
super(element, params.watch, false);
this._users = params.users || new UserCache();
}
userUpdated(nick) {
let user = this._users.get(nick);
if (!user) return;
user.fetch().then(() => {
let cards = this.element.querySelectorAll("div.profile-card.atbl-handled");
for (let i = 0; i < cards.length; ++i) {
let card = new UserElement(cards[i]);
if (card.nick === nick) {
if (user.b_action === "mark") card.mark();
else card.unmark();
break;
}
}
});
}
_updatePanel() {
// Получить список пользователей
const authors = BookElement.getAuthorList(this.element);
if (!authors.length) return;
// Загрузить пользователей
this._users.ensure(authors).then(() => {
try {
// Получить карточки пользователей и обработать их
this.element.querySelectorAll("div.profile-card:not(.atbl-handled)").forEach(ce => {
let card = new UserElement(ce);
let nick = card.nick;
if (nick && this._users.get(nick).b_action === "mark") {
card.mark();
}
ce.classList.add("atbl-handled");
});
} catch(err) {
Notification.display(err.message, "error");
}
});
}
}
/**
* Виджет для отображения параметров скрипта в настройках акканута
*/
class SettingsWidget extends Widget {
constructor(element, channel) {
super(element);
this._channel = channel;
}
update() {
if (this.element.classList.contains("atbl-handled")) return;
// Создать основную панель
let panel = document.createElement("div");
panel.classList.add("panel", "panel-default");
this.element.appendChild(panel);
let header = document.createElement("div");
header.classList.add("panel-heading");
header.textContent = "Параметры скрипта AuthorTodayBlackList";
panel.appendChild(header);
let body = document.createElement("div");
body.classList.add("panel-body");
panel.appendChild(body);
// Создать вкладки
this._makeTabs(body);
// Создать раздел настроек
this._makeSettings(body);
// Создать раздел со списком пользователей
this._makeAuthorList(body);
// Создать раздел импорта/экспорта данных
this._makeImportExport(body);
// Обработано
this.element.classList.add("atbl-handled");
// Активировать первую вкладку
body.querySelector("ul.nav>li").dispatchEvent(new Event("click", { bubbles: true }));
}
userUpdated(nick) {
let table = this.element.querySelector("div[data-name=authors] .atbl-table");
table && table.dispatchEvent(new CustomEvent("user-updated", { detail: nick }));
}
/**
* Создает элемент для отображения разделов параметров скрипта, со стилями оригинального сайта
*
* @param body_el Element HTML-элемент который станет родительским
*
* @return void
*/
_makeTabs(body_el) {
let div1 = document.createElement("div");
div1.classList.add("panel-heading", "panel-nav");
body_el.appendChild(div1);
let div2 = document.createElement("div");
div2.classList.add("nav-pills-v2");
div1.appendChild(div2);
let ul = document.createElement("ul");
ul.classList.add("nav", "nav-pills", "pill-left", "ml-lg");
div2.appendChild(ul);
[
[ "Настройки", "settings" ],
[ "Список авторов", "authors" ],
[ "Импорт/Экспорт", "imp-exp" ]
].forEach(it => {
let li = document.createElement("li");
li.dataset.target = it[1];
ul.appendChild(li);
let ae = document.createElement("a");
ae.classList.add("nav-link");
ae.setAttribute("href", "");
ae.textContent = it[0];
li.appendChild(ae);
});
ul.addEventListener("click", event => {
let li = event.target.closest("li[data-target]");
if (li) {
event.preventDefault();
ul.querySelectorAll("li[data-target]").forEach(ie => {
let act = null;
if (li === ie) {
if (!ie.classList.contains("active")) act = "add";
} else if (ie.classList.contains("active")) {
act = "remove";
}
if (act) {
ie.classList[act]("active");
let tc = body_el.querySelector(".atbl-tab-content[data-name=" + ie.dataset.target + "]");
tc && tc.classList[act]("active");
}
});
}
});
}
/**
* Раздел с настройками скрипта
*
* @param body_el Element HTML-элемент виджета, где будет отображен раздел
*
* @return void
*/
_makeSettings(body_el) {
let div1 = document.createElement("div");
div1.classList.add("atbl-tab-content");
div1.dataset.name = "settings";
body_el.appendChild(div1);
this._makeDecorSettings(div1);
this._makeDialogSettings(div1);
this._makeDebugSettings(div1);
}
/**
* Подраздел с настройками оформления
*
* @param parent Element Родительский элемент
*
* @return void
*/
_makeDecorSettings(parent) {
let fset = document.createElement("fieldset");
parent.appendChild(fset);
let lg = document.createElement("legend");
lg.textContent = "Внешний вид";
fset.appendChild(lg);
let form = document.createElement("form");
form.method = "post";
fset.appendChild(form);
let subtitle = document.createElement("b");
subtitle.textContent = "Закрывающая плашка";
form.appendChild(subtitle);
let content = document.createElement("div");
content.style.position = "relative";
content.style.display = "flex";
content.style.flexDirection = "column";
content.style.alignItems = "center";
content.style.justifyContent = "center";
content.style.minWidth = "10em";
content.style.minHeight = "6em";
content.innerHTML =
'<div style="color:#444;">Название книги</div><div style="color:#4582af;">Ссылка</div><div style="color:#9a938d">Авторы</div>';
form.appendChild(content);
let fence = document.createElement("div");
fence.style.position = "absolute";
fence.style.top = 0;
fence.style.left = 0;
fence.style.right = 0;
fence.style.bottom = 0;
content.appendChild(fence);
subtitle = document.createElement("b");
subtitle.textContent = "Настройки основного цвета закрывающей плашки";
form.appendChild(subtitle);
let row = document.createElement("div");
row.classList.add("atbl-settings-row");
form.appendChild(row);
let color1 = HTML.createColorPicker("Значение", "atbl-fence-color1", "#000000");
row.appendChild(color1);
color1 = color1.querySelector("input");
let transp1 = HTML.createRangeElement("Прозрачность", "atbl-fence-transp1", 70, 0, 100);
row.appendChild(transp1);
transp1 = transp1.querySelector("input");
subtitle = document.createElement("b");
subtitle.textContent = "Настройки дополнительного цвета закрывающей плашки";
form.appendChild(subtitle);
row = row.cloneNode(false);
form.appendChild(row);
let color2 = HTML.createColorPicker("Значение", "atbl-fence-color2", "#000000");
row.appendChild(color2);
color2 = color2.querySelector("input");
let transp2 = HTML.createRangeElement("Прозрачность", "atbl-fence-transp2", 80, 0, 100);
row.appendChild(transp2);
transp2 = transp2.querySelector("input");
let sbtn = HTML.createSubmitButton("save", "Сохранить", "primary");
form.appendChild(sbtn);
function updateFence() {
const c1 = hexToRgb(color1.value);
const o1 = (100 - parseInt(transp1.value)) / 100;
const c2 = hexToRgb(color2.value);
const o2 = (100 - parseInt(transp2.value)) / 100;
fence.style.backgroundImage = `repeating-linear-gradient(-45deg,rgba(${c1},${o1}) 0 10px,rgba(${c2},${o2}) 10px 20px)`;
}
const watched_controls = [ color1, color2, transp1, transp2 ];
form.addEventListener("change", event => {
if (!watched_controls.includes(event.target)) return;
updateFence();
});
form.addEventListener("submit", event => {
event.preventDefault();
fset.disabled = true;
(async () => {
try {
const c1 = color1.value;
const c2 = color2.value;
const o1 = (100 - parseInt(transp1.value)) / 100;
const o2 = (100 - parseInt(transp2.value)) / 100;
await DB.updateSetting("book.fence.color1", c1);
await DB.updateSetting("book.fence.color2", c2);
await DB.updateSetting("book.fence.opacity1", o1);
await DB.updateSetting("book.fence.opacity2", o2);
(new BroadcastChannel("colors-changed")).postMessage([ c1, c2, o1, o2 ]);
Notification.display("Настройки оформления успешно сохранены", "success");
} catch (err) {
Notification.display("Ошибка сохранения настроек оформления", "error");
console.error(err);
} finally {
fset.disabled = false;
}
})();
});
fset.disabled = true;
(async () => {
try {
color1.value = await DB.getSetting("book.fence.color1", "#000000");
color2.value = await DB.getSetting("book.fence.color2", "#000000");
transp1.value = Math.round((1 - await DB.getSetting("book.fence.opacity1", .2)) * 100);
transp2.value = Math.round((1 - await DB.getSetting("book.fence.opacity2", .3)) * 100);
updateFence();
fset.disabled = false;
} catch(err) {
Notification.display(err.message, "error");
}
})();
}
/**
* Подраздел с настройками формы для добавления автора
*
* @param parent Element Родительский элемент
*
* @return void
*/
_makeDialogSettings(parent) {
let dlg_fset = document.createElement("fieldset");
parent.appendChild(dlg_fset);
let lg = document.createElement("legend");
lg.textContent = "Добавление автора";
dlg_fset.appendChild(lg);
let dlg_form = document.createElement("form");
dlg_form.method = "post";
dlg_fset.appendChild(dlg_form);
let note = document.createElement("p");
note.textContent =
"Изначальные значения элементов формы в диалоге добавления нового автора." +
" Может быть удобно для быстрого наполнения списка авторов.";
dlg_form.appendChild(note);
let div2 = document.createElement("div");
div2.style.maxWidth = "20em";
dlg_form.appendChild(div2);
let lb = document.createElement("label");
lb.textContent = "Книги автора в виджетах";
div2.appendChild(lb);
let b_ac = HTML.createSelectbox("b_action", [
{ value: "none", text: "Не трогать" },
{ value: "mark", text: "Помечать" }
], "none" || "none");
div2.appendChild(b_ac);
let n_pr = HTML.createCheckbox("Отображать короткую заметку в профиле", "notes_profile", false);
dlg_form.appendChild(n_pr);
let d_ht = HTML.createCheckbox("Отображать подсказку в окне диалога", "dialog_hint", true);
dlg_form.appendChild(d_ht);
let btn1 = HTML.createSubmitButton("save", "Сохранить", "primary");
dlg_form.appendChild(btn1);
dlg_form.addEventListener("submit", event => {
event.preventDefault();
dlg_fset.disabled = true;
(async () => {
try {
await DB.updateSetting("authors.dialog.b_action", b_ac.value);
await DB.updateSetting("authors.dialog.notes_profile", n_pr.querySelector("input").checked);
await DB.updateSetting("authors.dialog.hint", d_ht.querySelector("input").checked);
Notification.display("Настройки успешно сохранены", "success");
} catch(err) {
Notification.display("Ошибка сохранения настроек", "error");
console.error(err);
} finally {
dlg_fset.disabled = false;
}
})();
});
dlg_fset.disabled = true;
(async () => {
try {
b_ac.value = await DB.getSetting("authors.dialog.b_action", "none");
n_pr.querySelector("input").checked = await DB.getSetting("authors.dialog.notes_profile", false);
d_ht.querySelector("input").checked = await DB.getSetting("authors.dialog.hint", true);
dlg_fset.disabled = false;
} catch(err) {
Notification.display(err.message, "error");
}
})();
}
/**
* Раздел для управления отладкой
*
* @param Element Родительский элемент
*
* @return void
*/
_makeDebugSettings(parent) {
let dlg_fset = document.createElement("fieldset");
parent.appendChild(dlg_fset);
let lg = document.createElement("legend");
lg.textContent = "Режим отладки";
dlg_fset.appendChild(lg);
let form = document.createElement("form");
form.method = "post";
dlg_fset.appendChild(form);
let note = document.createElement("p");
note.textContent =
"Опции, необходимые для разработки и отладки скрипта." +
" Если вы обычный пользователь, то скорее всего, они вам не нужны.";
form.appendChild(note);
let dhd = HTML.createCheckbox("Обрамлять обработанные блоки", "debug-handled", false);
form.appendChild(dhd);
let sbtn = HTML.createSubmitButton("save", "Сохранить", "primary");
form.appendChild(sbtn);
form.addEventListener("submit", event => {
event.preventDefault();
dlg_fset.disabled = true;
(async () => {
try {
await DB.updateSetting("debug.handled", dhd.querySelector("input").checked);
Notification.display("Опции отладки успешно изменены", "success");
} catch (err) {
Notification.display("Ошибка изменения отладочных опций", "error");
console.error(err);
} finally {
dlg_fset.disabled = false;
}
})();
});
dlg_fset.disabled = true;
(async () => {
try {
dhd.querySelector("input").checked = await DB.getSetting("debug.handled", false);
dlg_fset.disabled = false;
} catch (err) {
Notification.display(err.message, "error");
}
})();
}
/**
* Раздел со списком авторов
*
* @param body_el Element HTML-элемент виджета, где будет отображен раздел
*
* @return void
*/
_makeAuthorList(body_el) {
let div1 = document.createElement("div");
div1.classList.add("atbl-tab-content");
div1.dataset.name = "authors";
body_el.appendChild(div1);
let info_el = document.createElement("div");
info_el.appendChild(document.createTextNode("Записи: "));
let from_el = document.createElement("b");
info_el.appendChild(from_el);
info_el.appendChild(document.createTextNode(" - "));
let to_el = document.createElement("b");
info_el.appendChild(to_el);
info_el.appendChild(document.createTextNode(" из "));
let cnt_el = document.createElement("b");
info_el.appendChild(cnt_el);
div1.appendChild(info_el);
let table = this._usersTableElement();
div1.appendChild(table);
let tbody = table.querySelector("div.atbl-table-body");
let form = document.createElement("form");
form.style.display = "flex";
form.style.justifyContent = "space-between";
div1.appendChild(form);
let btn1 = HTML.createSubmitButton("prev", "Назад");
form.appendChild(btn1);
let btn2 = HTML.createSubmitButton("next", "Далее");
form.appendChild(btn2);
const pageSize = 20;
let records = 0;
let recFrom = 0;
let recTo = 0;
async function updateCount() {
records = await DB.usersCount();
cnt_el.textContent = records;
}
function updateRange() {
from_el.textContent = recFrom;
to_el.textContent = recTo;
}
function insertUserRow(user) {
let row = document.createElement("div");
row.classList.add("atbl-table-row");
row.appendChild(makeTableCell(user.nick));
row.appendChild(makeTableCell(user.fio));
row.appendChild(makeTableCell(user.shortNote()));
row.dataset.id = user.nick;
tbody.appendChild(row);
}
function makeTableCell(value) {
let el = document.createElement("div");
el.classList.add("atbl-table-cell");
if (value) el.textContent = value;
return el;
}
function enableButtons(enable) {
btn1.disabled = !enable || recFrom === 1;
btn2.disabled = !enable || recTo >= records;
}
function loadData() {
if (!recFrom) recFrom = 1;
if (recFrom > records) recFrom = Math.max(records - pageSize + 1, 1);
UserList.fetch(pageSize, recFrom - 1).then(list => {
while (tbody.firstChild) tbody.lastChild.remove();
list.forEach(usr => insertUserRow(usr));
recTo = recFrom + list.length - 1;
if (recFrom > recTo) recFrom = recTo;
updateRange();
updateCount().then(() => enableButtons(true));
}).catch(err => Notification.display(err.message, "error"));
}
form.addEventListener("submit", event => {
event.preventDefault();
enableButtons(false);
if (event.submitter.name === "next") {
recFrom = recTo + 1;
} else {
recFrom = Math.max(recFrom - pageSize, 1);
}
loadData();
});
tbody.addEventListener("click", event => {
let row = event.target.closest(".atbl-table-row");
if (row && row.dataset.id) {
let user = new User(row.dataset.id);
user.fetch().then(() => {
let dlg = new UserModalDialog(user, {
mobile: false,
title: "AuthorTodayBlockList - Автор"
});
dlg.show();
dlg.element.addEventListener("submitted", () => {
this._channel.postMessage(user.nick);
dlg.hide();
updateCount().then(() => {
updateRange();
loadData();
enableButtons(true);
});
});
});
}
});
table.addEventListener("user-updated", event => {
updateCount().then(() => loadData());
});
enableButtons(false);
updateCount().then(() => {
updateRange();
loadData();
});
}
/**
* Раздел для управления импортом и экспортом
*
* @param body_el Element HTML-элемент виджета, где будет отображен раздел
*
* @return void
*/
_makeImportExport(body_el) {
let div1 = document.createElement("div");
div1.classList.add("atbl-tab-content");
div1.dataset.name = "imp-exp";
body_el.appendChild(div1);
let exp_fset = document.createElement("fieldset");
div1.appendChild(exp_fset);
let lg = document.createElement("legend");
lg.textContent = "Экспорт данных";
exp_fset.appendChild(lg);
let exp_form = document.createElement("form");
exp_form.method = "post";
exp_fset.appendChild(exp_form);
let note = document.createElement("p");
note.textContent =
"Экспорт данных скрипта из внутреннего хранилища браузера для переноса в другой браузер или в целях" +
" создания страховой копии. Обратите внимание: никакие ваши персональные данные, в том числе данные" +
" вашего аккаунта, в указанный файл не выгружаются.";
exp_form.appendChild(note);
let exp_btn = HTML.createSubmitButton("export", "Экспорт", "primary");
exp_form.appendChild(exp_btn);
let imp_fset = document.createElement("fieldset");
div1.appendChild(imp_fset);
lg = document.createElement("legend");
lg.textContent = "Импорт данных";
imp_fset.appendChild(lg);
let imp_form = document.createElement("form");
imp_form.method = "post";
imp_form.enctype = "multipart/form-data";
imp_fset.appendChild(imp_form);
note = document.createElement("p");
note.textContent =
"Импорт данных скрипта из указанного файла. Во время импорта авторы не удаляются, а только" +
" добавляются и обновляются." +
" Внимание: Если автор уже существует, то его данные будут переписаны данными из файла.";
imp_form.appendChild(note);
let imp_file = document.createElement("input");
imp_file.type = "file";
imp_file.required = true;
imp_form.appendChild(imp_file);
let imp_btn = HTML.createSubmitButton("import", "Импорт", "danger");
imp_btn.disabled = true;
imp_form.appendChild(imp_btn);
function enableForms(enable) {
exp_fset.disabled = !enable;
imp_fset.disabled = !enable;
}
exp_form.addEventListener("submit", event => {
event.preventDefault();
enableForms(false);
this._exportData().catch(err => {
Notification.display(err.message, "error");
}).finally(() => {
enableForms(true)
});
});
imp_file.addEventListener("change", () => (imp_btn.disabled = !imp_file.files.length));
imp_form.addEventListener("submit", event => {
event.preventDefault();
enableForms(false);
this._importData(imp_file.files[0]).then(() => {
Notification.display("Данные успешно загружены");
imp_form.reset();
imp_btn.disabled = true;
(new BroadcastChannel("user-updated")).postMessage("*");
}).catch(err => {
Notification.display(err.message, "error");
}).finally(() => {
enableForms(true)
});
});
}
/**
* Создает пустую таблицу для отображения пользователей
*
* @return Element HTML элемент таблицы
*/
_usersTableElement() {
let table = document.createElement("div");
table.classList.add("atbl-table");
let hdr = document.createElement("div");
hdr.classList.add("atbl-table-header");
table.appendChild(hdr);
let row = document.createElement("div");
row.classList.add("atbl-table-row");
hdr.appendChild(row);
[ "Ник", "Имя", "Комментарий" ].forEach(title => {
let tc = document.createElement("div");
tc.classList.add("atbl-table-cell");
tc.textContent = title;
row.appendChild(tc);
});
let body = document.createElement("div");
body.classList.add("atbl-table-body");
table.appendChild(body);
return table;
}
/**
* Экспортирует данные и базы данных в файл json
*
* @return Promise с количеством записей
*/
async _exportData() {
try {
let list = await DB.usersList(-1);
let data = JSON.stringify({
type: "atbl-data",
version: 1,
users: list
}, undefined, 1);
let link = document.createElement("a");
link.download = "AuthorTodayBlackList-Export-" + Math.floor(Date.now() / 1000) + ".json";
link.href = URL.createObjectURL(new Blob([ data ], { type: "application/json" }));
link.click();
URL.revokeObjectURL(link.href);
return list.length;
} catch(err) {
Notification.display(err.message, "error");
}
}
/**
* Импортирует данные в базу данных из файла json
*
* @param file File Файл из HTML формы
*
* @return Promise с количеством записей
*/
_importData(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsText(file);
reader.addEventListener("load", () => {
try {
let data = null;
try {
data = JSON.parse(reader.result, (key, value) => {
if (key === "lastUpdate") return new Date(value);
return value;
});
} catch(err) {
throw new Error("Некорректный JSON файл");
}
if (data.type !== "atbl-data" || data.version !== 1) throw new Error("Некорректный формат");
let pa = (data.users || []).map(ud => User.fromObject(ud).save());
if (pa) {
Promise.all(pa).then(() => resolve()).catch(err => reject(err));
}
} catch(err) {
reject(err);
}
});
reader.addEventListener("error", err => reject(err));
});
}
}
/**
* Базовая страница сайта с боковыми виджетами
*/
class GeneralPage extends Page {
constructor() {
super();
this.name = "general";
this._aside = [];
}
update() {
super.update();
this._aside = [];
document.querySelectorAll("aside-widget").forEach(el => {
let w = new AsideBookShelfWidget(el, {
users: this.users,
layout: { list: ".work-widget-list .book-row", default: "list" }
});
this._aside.push(w);
w.update();
});
document.querySelectorAll(".aside-profile.profile-card").forEach(el => {
let w = new UserCardWidget(el, {
users: this.users
});
this._aside.push(w);
w.update();
});
}
_userUpdated(nick) {
this._aside.forEach(w => w.userUpdated(nick));
}
}
/**
* Класс для обновления страниц профиля пользователя/автора
*/
class ProfilePage extends GeneralPage {
constructor() {
super();
this.name = "profile";
this.user = null;
}
update() {
try {
this._widgets = [];
let el = document.querySelector(".profile-top-wrapper");
if (!el) return;
let ae = document.querySelector(".profile-info .profile-name h1>a[href^=\"/u/\"]");
if (!ae) return;
let res = /\/u\/([^\/]+)/.exec(ae.getAttribute("href"));
if (!res) return;
let fio = ae.textContent.trim();
if (fio === "") return;
this.user = new User(res[1], fio);
// Аватар профиля
el = document.querySelector("div.profile-avatar>div");
el && this._widgets.push(new ProfileAvatarWidget(el, this.user));
// Заметки профиля
el = document.querySelector("div.profile-info");
el && this._widgets.push(new ProfileNotesWidget(el, this.user));
// Меню профиля
el = document.querySelector("div.cover-buttons>ul.dropdown-menu");
if (el) {
let w = new ProfileMenuWidget(el);
w.menuItem && this._bindMenuItem(w.menuItem);
this._widgets.push(w);
}
// Получить данные пользователя и обновить виджеты
this.user.fetch().then(() => super.update());
} catch(err) {
Notification.display(err.message, "error");
}
}
/**
* Если пользователь профиля был обновлен в другой вкладке
*
* @param nick string Ник обновленного пользователя
*
* @return void
*/
_userUpdated(nick) {
if (this.user && this.user.nick === nick) {
this.user.fetch().then(() => this._widgets.forEach(w => w.update()));
};
super._userUpdated(nick);
}
/**
* Привязывает пункт меню к действию по созданию и отображению диалогового окна
*
* @param menu_item Element HTML-элемент пункта меню для привязки
*
* @return void
*/
_bindMenuItem(menu_item) {
menu_item.addEventListener("click", event => {
(async () => {
try {
let defaults = {};
if (this.user.empty) {
defaults.b_action = await DB.getSetting("authors.dialog.b_action");
defaults.notes_profile = await DB.getSetting("authors.dialog.notes_profile");
}
defaults.hint = await DB.getSetting("authors.dialog.hint", true);
let dlg = new UserModalDialog(this.user, {
mobile: false,
title: "AuthorTodayBlockList - Автор",
defaults: defaults
});
dlg.show();
dlg.element.addEventListener("submitted", () => {
this._widgets.forEach(w => w.update());
this._channel.postMessage(this.user.nick);
dlg.hide();
});
} catch(err) {
Notification.display(err.message, "error");
}
})();
});
}
}
/**
* Класс для отслеживания и обновления заглавной страницы сайта (8 виджетов с книгами)
*/
class MainPage extends GeneralPage {
constructor() {
super();
this.name = "main";
}
update() {
[
"mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
"recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
].forEach(id => {
let el = document.getElementById(id);
if (el) {
this._widgets.push(new SpinnerBookShelfWidget(el, {
users: this.users,
watch: false,
layout: { grid: ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned)", default: "grid" }
}));
}
});
super.update();
}
}
/**
* Класс для обновления страницы группировки книг по жанрам, популярности, etc
*/
class CategoriesPage extends GeneralPage {
constructor() {
super();
this.name = "categories";
}
update() {
this._widgets = [];
let el = document.getElementById("search-results");
if (el) {
this._widgets.push(new BookShelfWidget(el, {
users: this.users,
watch: true,
layout: {
list: ".book-row",
grid: ".book-shelf .bookcard",
table: ".books-table tbody tr",
selector: ".panel-actions.pull-right button.active i"
}
}));
el.style.overflowX = "hidden";
super.update();
}
}
}
/**
* Класс для обновления страницы результатов поиска по тексту
*/
class SearchPage extends Page {
constructor() {
super();
this.name = "search";
}
update() {
this._widgets = [];
let layout = {
grid: ".book-shelf .bookcard",
default: "grid"
};
switch ((new URL(document.location)).searchParams.get("category")) {
case null:
case "all":
{
let el = document.getElementById("search-results");
if (el) {
let w = new AjaxContainer(el, [
[ new UsersWidget(null, { users: this.users }), ".flex-list" ],
[ new BookShelfWidget(null, { users: this.users, layout: layout }), ".book-shelf" ]
]);
this._widgets.push(w);
}
}
break;
case "authors":
{
let u_el = document.getElementById("search-results");
u_el && this._widgets.push(new UsersWidget(u_el, { users: this.users, watch: true }));
}
break;
case "works":
{
let b_el = document.getElementById("search-results");
if (b_el) {
b_el.style.overflowX = "hidden";
layout.list = ".panel-body .book-row";
layout.table = ".books-table tbody tr";
layout.selector = ".panel-actions a.active i";
this._widgets.push(new BookShelfWidget(b_el, { users: this.users, watch: true, layout: layout }));
}
}
break;
}
super.update();
}
}
/**
* Класс для обновления страниц в библиотеках пользователей
*/
class LibraryPage extends GeneralPage {
constructor() {
super();
this.name = "library";
}
update() {
this._widgets = [];
const layout = {
grid: ".book-shelf .bookcard",
default: "grid"
};
const c_el = document.getElementById("search-results");
if (c_el) {
this._widgets.push(new BookShelfWidget(c_el, { users: this.users, watch: false, layout: layout }));
}
super.update();
}
}
/**
* Класс для обновления страниц коллекций
*/
class CollectionPage extends GeneralPage {
constructor() {
super();
this.name = "collection";
}
update() {
this._widgets = [];
let el = document.querySelector(".collection-page .collection-work-list");
if (el) {
this._widgets.push(new BookShelfWidget(el, { layout: { users: this.users, list: ".book-row", default: "list" } }));
}
super.update();
}
}
/**
* Класс для обновления страницы списка пользователей/авторов
*/
class UsersPage extends GeneralPage {
constructor() {
super();
this.name = "users";
}
update() {
let el = document.querySelector(".panel .panel-body .flex-list");
if (!el) return;
this._widgets = [ new UsersWidget(el, { users: this.users }) ];
super.update();
}
}
/**
* Класс для управления страницей личного кабинета
*/
class AccountPage extends Page {
constructor() {
super();
this.name = "account";
}
update() {
this._widgets = [];
try {
const scr_page = (new URL(document.location)).searchParams.get("script") === "atbl";
// Обновить меню, добавив пункт для скрипта
let menu = document.querySelector("aside nav ul.nav:not(.atbl-handled)");
if (menu) {
this._createMenuItems(menu);
menu.classList.add("atbl-handled");
if (scr_page) {
let active = menu.querySelector("li.active");
if (active) {
active.classList.remove("active");
menu.querySelector("li.atbl-settings").classList.add("active");
}
}
}
if (scr_page && document.location.pathname === "/account/settings") {
let sec = document.querySelector("#pjax-container section.content");
if (!sec) return;
// Активна страница настроек скрипта. Очистить ее.
while (sec.firstChild) sec.lastChild.remove();
// Добавить собственный виджет
this._widgets.push(new SettingsWidget(sec, this._channel));
// Обновить
super.update();
}
} catch(err) {
Notification.display(err.message, "error");
}
}
/**
* Создает элементы меню для отображения в основном меню личного кабинета пользователя
*
* @param menu Element HTML-элемент меню страницы настроек
*
* @return void
*/
_createMenuItems(menu) {
let item = document.createElement("li");
if (!menu.querySelector("li.Ox90-settings-menu")) {
item.classList.add("nav-heading", "Ox90-settings-menu");
menu.appendChild(item);
let span = document.createElement("span");
item.appendChild(span);
let img = document.createElement("i");
img.classList.add("icon-cogs", "icon-fw");
span.appendChild(img);
span.appendChild(document.createTextNode(" Внешние скрипты"));
item = document.createElement("li");
}
item.classList.add("atbl-settings");
menu.appendChild(item);
let ae = document.createElement("a");
ae.classList.add("nav-link");
ae.setAttribute("href", "/account/settings?script=atbl");
ae.textContent = "AutorTodayBlackList";
item.appendChild(ae);
}
}
/**
* Класс для манипуляции элементами карточки пользователя
*/
class UserElement {
/**
* Конструктор
*
* @param element Element HTML-элемент пользователя
*
* @return void
*/
constructor(element) {
this.element = element;
this.nick = this._getNick();
this._fence = element.querySelector(".atbl-fence-block");
}
/**
* Возвращает ники пользователей, найденные в переданном элементе без проверки на уникальность
*
* @param element Element HTML-элемент для сканирования
* @param selector string Уточняющий CSS селекор (необязательный параметр)
*
* @return array
*/
static userNick(element, selector) {
let list = [];
let sel = 'a[href^="/u/"]';
if (selector) sel = selector + " " + sel;
element.querySelectorAll(sel).forEach(function (ael) {
let r = /^\/u\/([^\/]+)/.exec(ael.getAttribute("href"));
if (r) list.push(r[1].trim());
});
return list;
}
/**
* Маркирует карточку пользователя
*
* @return void
*/
mark() {
if (this.element.classList.contains("atbl-marked")) return;
this._fence = document.createElement("div");
this._fence.classList.add("atbl-fence-block");
this.element.appendChild(this._fence);
this.element.classList.add("atbl-marked");
}
/**
* Снимает пометку с карточки пользователя
*
* @return void
*/
unmark() {
if (this._fence) {
this._fence.remove();
this._fence = null;
}
this.element.classList.remove("atbl-marked");
}
/**
* Извлекает ник пользователя
*
* @return string
*/
_getNick() {
return UserElement.userNick(this.element, ".card-content .user-info")[0];
}
}
/**
* Класс для манипуляции с боковым элементом пользователя (на страницах книг и блогов)
*/
class UserAsideElement extends UserElement {
_getNick() {
return UserElement.userNick(this.element)[0];
}
}
/**
* Базовый класс для манипуляции элементами книги разных видов
*/
class BookElement {
/**
* Конструктор
*
* @param element Element HTML-элемент книги
*
* @return void
*/
constructor(element) {
this.element = element;
this.authors = [];
this._fence = element.querySelector(".atbl-fence-block");
}
/**
* Проверяет, входил ли автор в список авторов книги
*
* @param nick string Ник автора для проверки
*
* @return bool
*/
hasAuthor(nick) {
return this.authors.includes(nick);
}
/**
* Маркирует книгу
*
* @return void
*/
mark() {
if (this.element.classList.contains("atbl-marked")) return;
this._fence = document.createElement("div");
this._fence.classList.add("atbl-fence-block", "noselect");
let note = document.createElement("div");
note.classList.add("atbl-note");
note.textContent = "Автор в ЧС";
this._fence.appendChild(note);
this.element.appendChild(this._fence);
this.element.classList.add("atbl-marked");
}
/**
* Снимает пометку с книги
*
* @return void
*/
unmark() {
if (this._fence) {
this._fence.remove();
this._fence = null;
}
this.element.classList.remove("atbl-marked");
}
/**
* Возвращает список авторов в переданном элементе, исключая повторения
*
* @param element Element HTML-элемент для поиска ссылок с авторами
* @param selector string Уточняющий CSS селектор (не обязательный параметр)
*
* @return Array
*/
static getAuthorList(element, selector) {
return Array.from(new Set(UserElement.userNick(element, selector)));
}
}
/**
* Класс для элемента книги в виде прямоугольно блока с обложкой и подробной информацией о книге
*/
class BookRowElement extends BookElement {
constructor(element) {
super(element);
this.authors = BookElement.getAuthorList(this.element, ".book-row-content .book-author");
}
}
/**
* Класс для элемента книги в виде карточки с обложкой и краткой информацией внизу
*/
class BookCardElement extends BookElement {
constructor(element) {
super(element);
this.authors = BookElement.getAuthorList(this.element, ".bookcard-footer .bookcard-authors");
}
}
/**
* Класс для элемента книги в виде строки таблицы, без обложки
*/
class BookTableElement extends BookElement {
constructor(element) {
super(element);
this.authors = BookElement.getAuthorList(this.element, "td:nth-child(2)");
}
}
/**
* Класс для отображения модального диалогового окна в стиле сайта
*/
class ModalDialog {
/**
* Конструктор
*
* @param params Object Объект с полями mobile (bool), title (string), body (Element)
*
* @return void
*/
constructor(params) {
this.element = null;
this._params = params;
this._backdrop = null;
}
/**
* Отображает модальное окно
*
* @return void
*/
show() {
if (this._params.mobile) {
this._show_m();
return;
}
this.element = document.createElement("div");
this.element.classList.add("modal", "fade", "in");
this.element.tabIndex = -1;
this.element.setAttribute("role", "dialog");
this.element.style.display = "block";
this.element.style.paddingRight = "12px";
let dlg = document.createElement("div");
dlg.classList.add("modal-dialog");
dlg.setAttribute("role", "document");
this.element.appendChild(dlg);
let ctn = document.createElement("div");
ctn.classList.add("modal-content");
dlg.appendChild(ctn);
let hdr = document.createElement("div");
hdr.classList.add("modal-header");
ctn.appendChild(hdr);
let hbt = document.createElement("button");
hbt.type = "button";
hbt.classList.add("close", "atbl-btn-close");
hdr.appendChild(hbt);
let sbt = document.createElement("span");
sbt.textContent = "x";
hbt.appendChild(sbt);
let htl = document.createElement("h4");
htl.classList.add("modal-title");
htl.textContent = this._params.title || "";
hdr.appendChild(htl);
let bdy = document.createElement("div");
bdy.classList.add("modal-body");
bdy.style.color = "#656565";
bdy.style.minWidth = "250px";
bdy.style.maxWidth = "max(500px,35vw)";
bdy.appendChild(this._params.body);
ctn.appendChild(bdy);
this._backdrop = document.createElement("div");
this._backdrop.classList.add("modal-backdrop", "fade", "in");
document.body.appendChild(this.element);
document.body.appendChild(this._backdrop);
document.body.classList.add("modal-open");
this.element.addEventListener("click", function(event) {
if (event.target === this.element || event.target.closest("button.atbl-btn-close")) {
this.hide();
}
}.bind(this));
this.element.addEventListener("keydown", function(event) {
if (event.code === "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
this.hide();
event.preventDefault();
}
}.bind(this));
this.element.focus();
}
/**
* Скрывает модальное окно и удаляет его элементы из DOM-дерева
*
* @return void
*/
hide() {
if (this._params.mobile) {
this._hide_m();
return;
}
if (this.element && this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
this.element.remove();
this.element = null;
document.body.classList.remove("modal-open");
}
}
/**
* Вариант метода show для мобильной версии сайта
*
* @return void
*/
_show_m() {
this.element = document.createElement("div");
this.element.classList.add("popup", "popup-screen-content");
this.element.style.overflow = "hidden";
let ctn = document.createElement("div");
ctn.classList.add("content-block");
this.element.appendChild(ctn);
let htl = document.createElement("h2");
htl.classList.add("text-center");
htl.textContent = this._params.title || "";
ctn.appendChild(htl);
let bdy = document.createElement("div");
bdy.classList.add("modal-body");
bdy.style.color = "#656565";
bdy.appendChild(this._params.body);
ctn.appendChild(bdy);
let cbt = document.createElement("button");
cbt.classList.add("mt", "button", "btn", "btn-default");
cbt.textContent = "Закрыть";
ctn.appendChild(cbt);
cbt.addEventListener("click", function(event) {
this._hide_m();
}.bind(this));
document.body.appendChild(this.element);
this.element.style.display = "block";
this.element.classList.add("modal-in");
this._turnOverlay_m(true);
this.element.focus();
}
/**
* Вариант метода hide для мобильной версии сайта
*
* @return void
*/
_hide_m() {
if (this.element) {
this.element.remove();
this.element = null;
this._turnOverlay_m(false);
}
}
/**
* Метод для управления положкой в мобильной версии сайта
*
* @param on bool Режим отображения подложки
*
* @return void
*/
_turnOverlay_m(on) {
let overlay = document.querySelector("div.popup-overlay");
if (!overlay && on) {
overlay = document.createElement("div");
overlay.classList.add("popup-overlay");
document.body.appendChild(overlay);
}
if (on) {
overlay.classList.add("modal-overlay-visible");
} else if (overlay) {
overlay.classList.remove("modal-overlay-visible");
}
}
}
/**
* Класс для отображения диалога с настройками автора
*/
class UserModalDialog extends ModalDialog {
/**
* Конструктор класса
*
* @param user User Автор
* @param params Object Данные с полями mobile и title
*
* @return void
*/
constructor(user, params) {
super(params);
this._user = user;
this._params.defaults = this._params.defaults || {};
}
show() {
this._params.body = this._createDialogContent();
super.show();
this.element.addEventListener("submit", event => {
event.preventDefault();
switch (event.submitter.name) {
case "save":
this._user.b_action = this.element.querySelector("select[name=b_action]").value;
this._user.notes = {
text: this.element.querySelector("textarea[name=notes_text]").value.trim(),
profile: this.element.querySelector("input[name=notes_profile]").checked
};
this._user.save().then(() => {
Notification.display("Данные успешно обновлены", "success");
this.element.dispatchEvent(new Event("submitted"));
}).catch(err => {
Notification.display("Ошибка обновления данных", "error");
console.warn("Ошибка обновления данных: " + err.message);
});
break;
case "delete":
if (confirm("Удалить автора из базы ATBL?")) {
this._user.delete().then(() => {
Notification.display("Запись успешно удалена", "success");
this.element.dispatchEvent(new Event("submitted"));
}).catch((err) => {
Notification.display("Ошибка удаления записи", "error");
console.warn("Ошибка удаления записи: " + err.message);
});
}
break;
}
});
}
/**
* Создает HTML-элемент form с полями ввода и кнопками для редактирования параметров автора
*
* @return Element
*/
_createDialogContent() {
let form = document.createElement("form");
let idiv = document.createElement("div");
idiv.style.display = "flex";
idiv.style.flexDirection = "column";
idiv.style.gap = "1em";
form.appendChild(idiv);
let tdiv = document.createElement("div");
tdiv.style.fontSize = "110%";
tdiv.style.borderBottom = "1px solid #e5e5e5";
tdiv.style.paddingBottom = "5px";
tdiv.appendChild(document.createTextNode("Параметры ATBL для пользователя "));
idiv.appendChild(tdiv);
let ustr = document.createElement("strong");
ustr.textContent = this._user.fio;
tdiv.appendChild(ustr);
let bsec = document.createElement("div");
idiv.appendChild(bsec);
let bttl = document.createElement("label");
bttl.textContent = "Книги автора в виджетах";
bsec.appendChild(bttl);
bsec.appendChild(
HTML.createSelectbox("b_action", [
{ value: "none", text: "Не трогать" },
{ value: "mark", text: "Помечать" }
], (this._user.empty ? this._params.defaults.b_action : this._user.b_action) || "none")
);
let nsec = document.createElement("div");
idiv.appendChild(nsec);
let nsp = document.createElement("label");
nsp.textContent = "Заметки";
nsec.appendChild(nsp);
let nta = document.createElement("textarea");
nta.name = "notes_text";
nta.style.width = "100%";
nta.spellcheck = true;
nta.maxlength = 1024;
nta.style.minHeight = "8em";
nta.placeholder = "Заметки об авторе";
nta.value = this._user.notes && this._user.notes.text || "";
nsec.appendChild(nta);
idiv.appendChild(HTML.createCheckbox(
"Отображать короткую заметку в профиле (только 1-я строчка)",
"notes_profile",
(this._user.empty ? this._params.defaults.notes_profile : this._user.notes.profile) || false
));
let usec = document.createElement("div");
idiv.appendChild(usec);
let uttl = document.createElement("label");
uttl.textContent = "Последнее обновление";
usec.appendChild(uttl);
let uval = document.createElement("input");
uval.readonly = true;
uval.disabled = true;
uval.classList.add("form-control");
uval.value = this._user.lastUpdate && this._user.lastUpdate.toLocaleString() || "нет";
usec.appendChild(uval);
if (this._params.defaults.hint) {
let hnt = document.createElement("div");
hnt.textContent = "Вы всегда можете отменить это действие в личном кабинете, в настройках скрипта.";
idiv.appendChild(hnt);
hnt = document.createElement("div");
hnt.textContent = "Обратите внимание: все настройки хранятся только в вашем браузере." +
" Если вы захотите пренести настройки в другой браузер, воспользуйтесь экспортом настроек в личном кабинете.";
idiv.appendChild(hnt);
}
let bdiv = document.createElement("div");
bdiv.classList.add("atbl-button-container");
form.appendChild(bdiv);
bdiv.appendChild(HTML.createSubmitButton("save", this._user.empty && "Добавить" || "Обновить", "success"));
let btn2 = HTML.createSubmitButton("delete", "Удалить", "danger");
btn2.disabled = this._user.empty;
bdiv.appendChild(btn2);
let btn3 = document.createElement("button");
btn3.classList.add("btn", "btn-default", "atbl-btn-close");
btn3.textContent = "Отмена";
bdiv.appendChild(btn3);
return form;
}
}
/**
* Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
*/
class Notification {
/**
* Конструктор. Вызвается из static метода display
*
* @param data Object Объект с полями text (string) и type (string)
*
* @return void
*/
constructor(data) {
this._data = data;
this._element = null;
}
/**
* Возвращает HTML-элемент блока с текстом уведомления
*
* @return Element HTML-элемент для добавление в контейнер уведомлений
*/
element() {
if (!this._element) {
this._element = document.createElement("div");
this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
let msg = document.createElement("div");
msg.classList.add("toast-message");
msg.textContent = "ATBL: " + this._data.text;
this._element.appendChild(msg);
this._element.addEventListener("click", () => this._element.remove());
setTimeout(() => {
this._element.style.transition = "opacity 2s ease-in-out";
this._element.style.opacity = "0";
setTimeout(() => {
let ctn = this._element.parentElement;
this._element.remove();
if (!ctn.childElementCount) ctn.remove();
}, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
}, 10000); // Длительность отображения уведомления - 10 секунд
}
return this._element;
}
/**
* Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
*
* @param text string Текст уведомления
* @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
*
* @return void
*/
static display(text, type) {
let ctn = document.getElementById("toast-container");
if (!ctn) {
ctn = document.createElement("div");
ctn.id = "toast-container";
ctn.classList.add("toast-top-right");
ctn.setAttribute("role", "alert");
ctn.setAttribute("aria-live", "polite");
document.body.appendChild(ctn);
}
ctn.appendChild((new Notification({ text: text, type: type })).element());
}
}
//----------
/**
* Добавляет стилевые блоки на страницу
*
* @param string css Текстовая строка CSS-блока вида ".selector1 {...} .selector2 {...}"
* @param string id Id элемента стилей (не обязательно)
*
* @return void
*/
function addStyle(css, id) {
const style = document.getElementById("atbl_styles") || (function() {
const style = document.createElement('style');
style.type = 'text/css';
style.id = id || "atbl_styles";
document.head.appendChild(style);
return style;
})();
const sheet = style.sheet;
sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}
/**
* Преобразует hex цвет в rgb представление ("#ff0000" -> "255,0,0")
*
* @param hex string HEX представление цвета для преобразования
*
* @return string
*/
function hexToRgb(hex) {
const res = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
if (res) {
return "" + parseInt(res[1], 16) + "," + parseInt(res[2], 16) + "," + parseInt(res[3], 16);
}
return "0,0,0";
}
// Проверяем доступность базы данных
if (!indexedDB) return; // База недоступна. Возможно используется приватный режим просмотра.
// Старт скрипта по готовности DOM-дерева
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", start);
else start();
})();