// ==UserScript==
// @name AuthorTodayBlackList
// @name:ru AuthorTodayBlackList
// @namespace 90h.yy.zz
// @version 0.3.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
* - Поменять иконку черного списка в профиле автора на что-нибудь более подходящее и заметное
* - Сделать немедленное обновление содержимого заметки в заголовке профиля, если заметка была отредактирована в диалоговом окне
* - Добавить возможность скрытия книг автора в виджетах, если скрытие возможно
* - Список записей в базе данных по типу как в https://author.today/account/ignorelist
* - Импорт/экспорт базы скрипта для переноса в другой браузер
* - Адаптация к мобильной версии сайта
*/
(function start() {
"use strict";
/**
* Старт скрипта сразу после загрузки DOM-дерева.
* Тут настраиваются стили, инициируется объект для текущей страницы,
* вешается отслеживание измерений страницы скриптами сайта
*
* @return void
*/
function start() {
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%; background-image: repeating-linear-gradient(-45deg,rgba(0,0,0,.1) 0 10px,rgba(0,0,0,.2) 10px 20px); 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; width:30%; min-height:30%; color:#fff; font-weight:bold; font-size:22px; align-items:center; justify-content:center; background-color:#282828; border:2px ridge #666; opacity:.92 }");
addStyle(".atbl-book-banner, .atbl-fence-block { display:flex; align-items:center; justify-content:center; }");
addStyle(".atbl-fence-block.atbl-open { left:95%; }");
let page = null;
function updatePageInstance() {
let path = document.location.pathname;
if (path === "/") {
if (!page || page.name !== "main") {
page = new MainPage();
}
return;
}
let res = /^\/u\/([^\/]+)/.exec(path);
if (res) {
let nick = res[1];
if (!page || page.name !== "profile" || page.user.nick !== nick) {
page = new ProfilePage(nick);
}
return;
}
res = /^\/work\/(?:genre|recommended|discounts)\//.exec(path);
if (res) {
if (!page || page.name !== "categories") {
page = new CategoriesPage();
}
return;
}
page = null;
}
// Идентификация и обновление страницы
updatePageInstance();
page && page.update();
// Отслеживание изменения контейнера на случай обновления страницы через AJAX запрос
// Потомков не отслеживает, только изменение списка детей.
let ajax_box = document.getElementById("pjax-container");
if (ajax_box) {
(new MutationObserver(function() {
updatePageInstance();
page && page.update();
})).observe(ajax_box, {childList: true });
}
}
/**
* Создает единичный элемент типа checkbox со стилями сайта
*
* @param title string Подпись для checkbox
* @param title string Значение атрибута name у checkbox
* @param checked bool Начальное состояние checkbox
*
* @return Element HTML-элемент для последующего добавления на форму
*/
function 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 = document.createElement("input");
input.type = "checkbox";
input.name = 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-элемент для последующего добавления на форму
*/
function 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;
}
//----------------------------
//---------- Классы ----------
//----------------------------
/**
* Экземпляр класса для работы с базой данных браузера (IndexedDB)
* Все методы класса работают в асинхронном режиме
*/
let DB = new class {
constructor() {
this._dbh = null;
}
/**
* Получение данных о пользователе по его 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) {
resolve(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));
}
/**
* Гарантирует соединение с базой данных
*
* @return Promise Промис, который возвращает объект для работы с базой данных
*/
_ensureOpen() {
return new Promise(function(resolve, reject) {
if (this._dbh) {
resolve(this._dbh);
return;
}
let req = indexedDB.open("atbl_main_db", 1);
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;
switch (event.oldVersion) {
case 0:
if (!db.objectStoreNames.contains("users")) {
db.createObjectStore("users", { keyPath: "nick" });
}
break;
}
};
}.bind(this));
}
}();
/**
* Класс для работы с данными автора или пользователя.
*/
class User {
/**
* Конструктор класса
*
* @param nick string Ник пользователя для идентификации
* @param fio string Фамилия, имя пользователя. Или что там записано. Не обязательно.
*
* @return void
*/
constructor(nick, fio) {
this.nick = nick;
this.fio = fio || "";
this.notes = null;
this.lastUpdate = null;
this.b_action = null;
this._requests = [];
}
/**
* Обновляет данные пользователя из базы данных
*
* @return Promise Промис, гарантирует обновление полей пользователя
*/
fetch() {
if (!this._requests.length) {
return DB.fetchUser(this).then(function(res) {
if (res) {
this.notes = res.notes || {};
this.lastUpdate = res.lastUpdate;
this.b_action = res.b_action;
if (!this.fio) this.fio = res.fio;
}
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() {
return DB.updateUser(this);
}
/**
* Удаляет пользователя из базы данных
*
* @return Promise Промис, гарантирующий удаление данных пользователя
*/
async delete() {
await DB.deleteUser(this);
this.notes = null;
this.b_action = null;
this.lastUpdate = null;
}
}
/**
* Класс для работы со списком пользователей в режиме кэша.
* Предназначен для того, чтобы избежать дублирование запросов к базе данных.
* Расширяет стандартный класс 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;
}
/**
* Метод для запуска обновления страницы сайта
*/
update() {
}
}
/**
* Класс с общими методами вспомогательного характера
*/
class Utils {
/**
* Удаляет завлекательные элементы типа "Эксклюзив", "Скидка"
*
* @param el Element HTML-элемент для чистки
*
* @return void
*/
static removeAttraction(el) {
[ "div.ribbon", "div.bookcard-discount" ].forEach(function(selector) {
let e = el.querySelector(selector);
e && e.remove();
});
}
/**
* Извлекает ники авторов из HTML-элементов
*
* @param el Element HTML-элемент для поиска ссылок с авторами
*
* @return array Массив с никами авторов
*/
static getAuthorList(el) {
let al = [];
el.querySelectorAll('a[href^="/u/"]').forEach(function(ae) {
let r = /^\/u\/([^\/]+)/.exec(ae.getAttribute("href"));
if (r) al.push(r[1]);
});
return al;
}
}
/**
* Класс для обновления страниц профиля пользователя/автора
*/
class ProfilePage extends Page {
/**
* Конструктор класса
*
* @params nick string Ник пользователя из страницы профиля
*
* @return void
*/
constructor(nick) {
super();
this._menu = null;
this._badge = null;
let fio_el = document.querySelector("h1>a[href^=\"/u/\"]");
if (fio_el) {
let fio = fio_el.textContent.trim();
if (fio !== "") {
this.user = new User(nick, fio);
this.name = "profile";
}
}
}
/**
* Метод для асинхронного обновления страницы.
* - Добавляет значок на аватар пользователя, если он в черном списке
* - Добавляет заметку, если она есть и разрешено ее отображение (только первая строчка заметки)
* - Добавляет пункт меню в меню профиля пользователя для вызова диалога настроек
*
* @return void
*/
update() {
this.user.fetch().then(() => {
this._updateProfileAvatar();
this._updateProfileNotes();
this._updateProfileMenu();
});
}
/**
* Отображение значка на аватаре пользователя, если это необходимо
*
* @return void
*/
_updateProfileAvatar() {
if (!this.user.b_action || this.user.b_action === "none") {
if (this._badge) {
this._badge.remove();
}
return;
}
if (!this._badge)
this._createBadgeElement();
if (!document.contains(this._badge)) {
let av_el = document.querySelector("div.profile-avatar>a");
if (av_el) {
av_el.appendChild(this._badge);
}
}
}
/**
* Отображение заметки в профиле пользователя, если это необходимо
*
* @return void
*/
_updateProfileNotes() {
if (this.user.notes && this.user.notes.profile && this.user.notes.text) {
let p_info = document.querySelector("div.profile-info");
if (p_info) {
if (!this._notes) {
this._notes = document.createElement("div");
this._notes.classList.add("atbl-profile-notes");
let ntxt = this.user.notes.text;
let eoli = ntxt.indexOf("\n");
if (eoli !== -1) ntxt = ntxt.substring(0, eoli).trim();
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);
}
if (!p_info.contains(this._notes)) {
p_info.appendChild(this._notes);
}
}
} else if (this._notes) {
this._notes.remove();
}
}
/**
* Добавление пункта меню для вызова диалога настроек
*
* @return void
*/
_updateProfileMenu() {
let menu_el = document.querySelector("div.cover-buttons>ul.dropdown-menu");
if (menu_el && menu_el.children.length) {
if (!this._menu) {
let item = menu_el.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) {
iitem.setAttribute("class", "icon-pencil mr");
iitem.setAttribute("style", "margin-right:17px !important;");
aitem.removeAttribute("onclick");
aitem.childNodes[ccnt - 1].textContent = "AuthorTodayBlackList (ATBL)";
aitem.addEventListener("click", function() {
let usr = this.user;
let dlg = new ModalDialog({
mobile: false,
title: "AuthorTodayBlockList - Автор",
body: this._createProfileDialogContent()
});
dlg.show();
dlg.element.addEventListener("submit", function(event) {
event.preventDefault();
switch (event.submitter.name) {
case "save":
this.user.b_action = dlg.element.querySelector("select[name=b_action]").value;
this.user.notes = {
text: dlg.element.querySelector("textarea[name=notes_text]").value.trim(),
profile: dlg.element.querySelector("input[name=notes_profile]").checked
};
this.user.save().then(function() {
this._updateProfileAvatar();
this._updateProfileNotes();
Notification.display("Данные успешно обновлены", "success");
dlg.hide();
}.bind(this)).catch(function(err) {
Notification.display("Ошибка обновления данных", "error");
console.warn("Ошибка обновления данных: " + err.message);
});
break;
case "delete":
if (confirm("Удалить автора из базы ATBL?")) {
this.user.delete().then(function() {
this._updateProfileAvatar();
this._updateProfileNotes();
Notification.display("Запись успешно удалена", "success");
dlg.hide();
}.bind(this)).catch(function(err) {
Notification.display("Ошибка удаления записи", "error");
console.warn("Ошибка удаления записи: " + err.message);
});
}
break;
}
}.bind(this));
}.bind(this));
this._menu = item;
}
}
if (this._menu && !menu_el.contains(this._menu)) {
menu_el.appendChild(this._menu);
}
}
}
/**
* Создает HTML-элемент form с полями ввода и кнопками для отображения на модальной форме в профиле пользователя
*
* @return Element
*/
_createProfileDialogContent() {
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.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(
createSelectbox("b_action", [
{ value: "none", text: "Не трогать" },
{ value: "mark", text: "Помечать" }
], this.user.b_action || "none")
);
let nsec = document.createElement("div");
idiv.appendChild(nsec);
let nsp = document.createElement("span");
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(createCheckbox(
"Отображать заметку в профиле (только 1-я строчка)",
"notes_profile",
this.user.notes && this.user.notes.profile || false
));
let bdiv = document.createElement("div");
bdiv.classList.add("mt", "text-center");
form.appendChild(bdiv);
let btn1 = document.createElement("button");
btn1.type = "submit";
btn1.name = "save";
btn1.classList.add("btn", "btn-success");
btn1.textContent = "Обновить";
bdiv.appendChild(btn1);
let btn2 = document.createElement("button");
btn2.type = "submit";
btn2.name = "delete";
btn2.classList.add("btn", "btn-danger", "ml");
btn2.textContent = "Удалить запись";
bdiv.appendChild(btn2);
let btn3 = document.createElement("button");
btn3.classList.add("btn", "btn-default", "atbl-btn-close", "ml");
btn3.textContent = "Отмена";
bdiv.appendChild(btn3);
return form;
}
/**
* Создает элемент значка для аватара автора, сообщающего, что автор находистя в ЧС
*
* @return Element
*/
_createBadgeElement() {
this._badge = document.createElement("div");
this._badge.setAttribute("class", "atbl-badge");
let span = document.createElement("span");
span.appendChild(document.createTextNode("ЧС"));
this._badge.appendChild(span);
}
}
/**
* Класс для отслеживания и обновления заглавной страницы сайта
*/
class MainPage extends Page {
constructor() {
super();
this.name = "main";
this._users = new UserCache();
}
/**
* Метод для асинхронного обновления страницы сайта.
* В случае, если книги подгружаются в панель отдельным запросом,
* то на такую панель вешается наблюдатель и панель обновляется по готовности.
*
* @return void
*/
update() {
[
"mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
"recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
].forEach(function(panel_id) {
let panel_el = document.getElementById(panel_id);
if (panel_el)
this._scanPanel(panel_el);
}.bind(this));
}
/**
* Сканирует указанную панель, ждет окончательную загрузку панели и запускает обновление
*
* @param panel_el Element HTML-элемент панели для сканирования
*
* @return void
*/
_scanPanel(panel_el) {
function getSpinner() {
return panel_el.querySelector(".widget-spinner");
}
let cards = this._scanBookcards(panel_el);
if (cards) {
this._ensureAuthors(cards.authors).then(() => this._updateBookcovers(cards.covers, cards.authors));
} else if (getSpinner()) {
// Панель обновляется фоновым запросом
// Повесить отслеживание изменений в панели
(new MutationObserver(function(mutations, observer) {
if (!getSpinner()) {
observer.disconnect();
let cards = this._scanBookcards(panel_el);
if (cards) {
this._ensureAuthors(cards.authors).then(() => this._updateBookcovers(cards.covers, cards.authors));
}
}
}.bind(this))).observe(panel_el, { childList: true });
}
}
/**
* Убеждается, что все указанные авторы имеются в кэше
*
* @param authors array Массив массивов с никами авторов
*
* @return Promise Промис, гарантирующий наличие авторов в кэше
*/
_ensureAuthors(authors) {
return this._users.ensure(
authors.reduce(function(r, a) {
a.forEach(a2 => r.push(a2));
return r;
}, [])
);
}
/**
* Сканирует карточки книг в указанной панели. Возвращает объект с массивом обложек и авторов
*
* @param panel_el Element HTML-элемент панели с карточками книг
*
* @return Object Объект с полями covers и authors
*/
_scanBookcards(panel_el) {
let covers = [];
panel_el.querySelectorAll(
".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled) .book-cover"
).forEach(function(node) {
covers.push(node);
});
if (!covers.length) return;
let authors = [];
// Уточнение .bookcard-footer требуется по причине того, что bookcard-authors может быть на самой обложке, когда нет изображения
panel_el.querySelectorAll(
".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled) .bookcard-footer>.bookcard-authors"
).forEach(function(node) {
let al = Utils.getAuthorList(node);
if (!al.length) throw new Error("У книги нет автора!");
authors.push(al);
});
return { covers: covers, authors: authors };
}
/**
* Обновляет обложки книг, в случае, если это необходимо
* Если у книги несколько авторов, то затрагиваются только те книги,
* у которых все авторы имеют необходимую пометку.
*
* @param bookcovers array Массив HTML-элеметов обложек
* @param authors array Массив массивов авторов книг
*
* @return void
*/
_updateBookcovers(bookcovers, authors) {
for (let i = 0; i < bookcovers.length; ++i) {
let bcard = bookcovers[i];
if (authors[i].every(id => this._users.get(id).b_action === "mark")) {
Utils.removeAttraction(bcard);
bcard.appendChild(this._createBannerElement());
}
bcard.closest(".bookcard.slick-slide").classList.add("atbl-handled");
}
}
/**
* Создает HTML-элемент баннера для обложки книги
*
* @return Element HTML-элемент баннера для добавление в DOM
*/
_createBannerElement() {
let banner = document.createElement("div");
banner.classList.add("atbl-book-banner");
let span = document.createElement("span");
span.textContent = "Автор в ЧС";
banner.appendChild(span);
return banner;
}
}
/**
* Класс для обновления страницы группировки книг по жанрам, популярности, etc
*/
class CategoriesPage extends Page {
constructor() {
super();
this.name = "categories";
this._users = new UserCache();
this._panel = null;
}
/**
* Метод асинхронного обновления страницы с книгами
* Этот тип страницы может быть обновлен тремя способами:
* - Классически, через обновление вкладки
* - Обновлением всего блока сайта скриптом. Например при выборе следующей страницы категории
* - Обновлением только блока с результатами запроса. Например при выборе жанра в верхней панели.
* Поэтому необходимо повесить дополнительный наблюдатель на панель с результами запроса
*
* @return void
*/
update() {
this._panel = document.getElementById("search-results");
if (!this._panel) return;
// Настроить скрытие метки по клику
this._panel.style.overflowX = "hidden";
this._panel.addEventListener("click", function(event) {
let fence = event.target.closest(".atbl-fence-block");
if (fence) fence.classList.toggle("atbl-open");
});
// Обновить панель
this._updatePanel();
// Установить наблюдатель на панель результатов
(new MutationObserver(function() {
if (!this._panel.querySelector(".overlay")) this._updatePanel();
}.bind(this))).observe(this._panel, {childList: true });
}
/**
* Анализирует панель блока результатов.
*
* @return void
*/
_updatePanel() {
try {
// Искать панельки с обложками и авторами
let covers_el = this._panel.querySelectorAll(".book-row:not(.atbl-handled)");
let authors = [];
this._panel.querySelectorAll(".book-row:not(.atbl-handled) .book-row-content .book-author").forEach(function(el) {
let al = Utils.getAuthorList(el);
if (!al.length) throw new Error("У книги нет автора!");
authors.push(al);
});
if (covers_el.length === 0 || covers_el.length !== authors.length) {
// Панельки не найдены или количество панелек с обложками и количество блоков с авторами различаются.
if (!this._panel.querySelector(".book-row")) {
Notification.display("Книги не найдены.", "warning");
return;
}
// Все плохо. Выдать сообщение.
throw new Error("Ошибка анализа страницы!");
}
// Запросить данные об авторах
this._users.ensure(
authors.reduce(function(r, a) {
a.forEach(a2 => r.push(a2));
return r;
}, [])
).then(function() {
// Начать обновлять элементы с книгами
this._updateCovers(covers_el, authors);
}.bind(this)).catch(function(err) {
Notification.display(err.message, "error");
});
} catch(err) {
Notification.display(err.message, "error");
return;
}
}
/**
* Обновляет панели по списку элементов и авторов
*
* @param covers_el NodeList Список элементов для проверки необходимоси обновления
* @param authors array Список авторов, по которому будет проверяться необходимость обновления
*
* @return void
*/
_updateCovers(covers_el, authors) {
for (let i = 0; i < covers_el.length; ++i) {
let el = covers_el[i];
if (authors[i].every(id => this._users.get(id).b_action === "mark")) {
Utils.removeAttraction(el);
this._markBookRow(el);
}
el.classList.add("atbl-handled");
}
}
/**
* Помечает строчку книги
*
* @param el Element Элемент строчки с книгой
*
* @return void
*/
_markBookRow(el) {
let fence = document.createElement("div");
fence.classList.add("atbl-fence-block", "noselect");
fence.style.top = "-10px";
let note = document.createElement("div");
note.classList.add("atbl-note");
note.textContent = "Автор в ЧС";
fence.appendChild(note);
el.appendChild(fence);
}
}
/**
* Класс для отображения модального диалогового окна в стиле сайта
*/
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 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 {...}"
*
* @return void
*/
function addStyle(css) {
const style = document.getElementById("atbl_stylesheet") || (function() {
const style = document.createElement('style');
style.type = 'text/css';
style.id = "atbl_stylesheet";
document.head.appendChild(style);
return style;
})();
const sheet = style.sheet;
sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}
// Проверяем доступность базы данных
if (!indexedDB) return; // База недоступна. Возможно используется приватный режим просмотра.
// Старт скрипта по готовности DOM-дерева
if (document.readyState === "loading")
window.addEventListener("DOMContentLoaded", start);
else
start();
}());