// ==UserScript==
// @name AuthorTodayBlackList
// @name:ru AuthorTodayBlackList
// @namespace 90h.yy.zz
// @version 0.6.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%; 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 { display:flex; align-items:center; justify-content:center; }");
addStyle(".book-row>.atbl-fence-block.atbl-open, tr>.atbl-fence-block.atbl-open { left:95%; }");
addStyle(".bookcard>.atbl-fence-block.atbl-open { top:-85%; }");
addStyle(".bookcard.atbl-marked { overflow-y:hidden; }");
addStyle("tr.atbl-marked { position:relative; }");
addStyle(".book-row>.atbl-fence-block, tr>.atbl-fence-block { background-image: repeating-linear-gradient(-45deg,rgba(0,0,0,.1) 0 10px,rgba(0,0,0,.2) 10px 20px); }");
addStyle(".bookcard>.atbl-fence-block { background-image: repeating-linear-gradient(-45deg,rgba(0,0,0,.3) 0 10px,rgba(0,0,0,.4) 10px 20px); }");
addStyle(".book-row>.atbl-fence-block .atbl-note { 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; }");
let page = null;
function updatePageInstance() {
const 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;
}
if (path.startsWith("/work/genre/") || path.startsWith("/work/recommended/") || path.startsWith("/work/discount/")) {
if (!page || page.name !== "categories") {
page = new CategoriesPage();
}
return;
}
if (path === "/search") {
if (!page || page.name !== "search") {
page = new SearchPage();
}
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;
this._root = null; // Корневой элемент страницы
this._channel = new BroadcastChannel("user-updated");
this._channel.onmessage = event => this._userUpdated(event.data);
this._layoutselector = null;
}
/**
* Метод для запуска обновления страницы сайта
*/
update() {
}
/**
* Возвращает строку с идентификатором действия для указанной книги
*
* @param book BookElement Экземпляр класса книги
*
* @return string Возможные значения: 'mark', 'unmark', 'none'
*/
getBookAction(book) {
if (this._users && book.authors.length) {
if (book.authors.every(nick => this._users.get(nick).b_action === "mark")) {
return "mark";
}
return "unmark";
}
return "none";
}
/**
* Метод вызывается, когда данные какого-либо пользователя были изменены в другой вкладке
*
* @param nick string Пользователь, который был изменен
*
* @return void
*/
_userUpdated(nick) {
}
}
/**
* Базовый класс для обновления страниц с книгами
*/
class BookShelfPage extends Page {
/**
* Конструктор класса
*
* @return void
*/
constructor() {
super();
this._users = new UserCache();
this._layout = { name: null, selector: null, list: null, grid: null, table: null };
}
/**
* Метод асинхронного обновления блока с книжной полкой
* Этот тип страницы может быть обновлен тремя способами:
* - Классически, через обновление вкладки
* - Обновлением всего блока сайта скриптом. Например при выборе следующей страницы категории
* - Обновлением только блока с результатами запроса. Например при выборе жанра в верхней панели.
* Поэтому необходимо повесить дополнительный наблюдатель на панель с результами запроса
*
* @return void
*/
update() {
if (!this._root) return;
// Настроить скрытие плашки по клику
this._root.addEventListener("click", function(event) {
let fence = event.target.closest(".atbl-fence-block");
if (fence) fence.classList.toggle("atbl-open");
});
// Установить наблюдатель на панель результатов
(new MutationObserver(function() {
if (!this._root.querySelector(".overlay")) this._updatePanel();
}.bind(this))).observe(this._root, { childList: true });
// Сканировать и обновить панель с книгами
this._updatePanel();
super.update();
}
/**
* Извлекает из панели список авторов, проверяет их настройки и обновляет блок с книгами
*
* @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._root);
if (!authors.length) return;
this._users.ensure(authors).then(() => {
try {
// Получить элементы книг и обработать их
let books = this._root.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._root.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._root || 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);
}
}
/**
* Класс для обновления страниц профиля пользователя/автора
*/
class ProfilePage extends Page {
/**
* Конструктор класса
*
* @params nick string Ник пользователя из страницы профиля
*
* @return void
*/
constructor(nick) {
super();
this.user = null;
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() {
if (!this.user) return;
this.user.fetch().then(() => {
this._updateProfileAvatar();
this._updateProfileNotes();
this._updateProfileMenu();
});
}
/**
* Какой-то пользователь был обновлен в другой вкладке.
* Если совпадает с пользователем профиля, то обновить
*
* @param nick string Ник обновленного пользователя
*
* @return void
*/
_userUpdated(nick) {
if (this.user && this.user.nick === nick) this.update();
}
/**
* Отображение значка на аватаре пользователя, если это необходимо
*
* @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) {
let ntxt = this.user.notes.text;
let eoli = ntxt.indexOf("\n");
if (eoli !== -1) ntxt = ntxt.substring(0, eoli).trim();
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 (!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();
this._channel.postMessage(this.user.nick);
dlg.hide();
Notification.display("Данные успешно обновлены", "success");
}.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();
this._channel.postMessage(this.user.nick);
dlg.hide();
Notification.display("Запись успешно удалена", "success");
}.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 || "mark")
);
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();
this._panels = [
"mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
"recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
];
}
/**
* Метод для асинхронного обновления страницы сайта.
* В случае, если книги подгружаются в панель отдельным запросом,
* то на такую панель вешается наблюдатель и панель обновляется по готовности.
*
* @return void
*/
update() {
this._panels.forEach(function(panel_id) {
let panel_el = document.getElementById(panel_id);
if (panel_el) {
// Обработчик для клика по метке
panel_el.addEventListener("click", function(event) {
let fence = event.target.closest(".atbl-fence-block");
fence && fence.classList.toggle("atbl-open");
});
// Запустить сканирование панели
this._scanPanel(panel_el);
}
}.bind(this));
}
/**
* Сканирует указанную панель, ждет окончательную загрузку панели и запускает обновление
*
* @param panel_el Element HTML-элемент панели для сканирования
*
* @return void
*/
_scanPanel(panel_el) {
function getSpinner() {
return panel_el.querySelector(".widget-spinner");
}
if (!getSpinner()) {
this._updatePanel(panel_el);
} else {
// Панель обновляется фоновым запросом
// Повесить отслеживание изменений в панели
(new MutationObserver(function(mutations, observer) {
if (!getSpinner()) {
observer.disconnect();
this._updatePanel(panel_el);
}
}.bind(this))).observe(panel_el, { childList: true });
}
}
/**
* Получает список авторов панели и запускает обновление обложек книг, если необходимо
*
* @param panel_el Element HTML-элемент панели для обновления
*
* @return void
*/
_updatePanel(panel_el) {
try {
// Получить список авторов панели и запустить их синхронизацию
const authors = BookElement.getAuthorList(panel_el, ".bookcard-footer>.bookcard-authors");
if (!authors.length) return;
this._users.ensure(authors).then(() => {
// Теперь необходимо обновить книги
panel_el.querySelectorAll(
".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled)"
).forEach(be => {
let book = new BookCardElement(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;
user.fetch().then(() => {
this._panels.forEach(p_id => {
document.querySelectorAll("#" + p_id + " .bookcard.atbl-handled").forEach(be => {
let book = new BookCardElement(be);
if (book.hasAuthor(nick)) {
switch (this.getBookAction(book)) {
case "mark":
book.mark();
break;
case "unmark":
book.unmark();
break;
}
}
});
});
});
}
}
/**
* Класс для обновления страницы группировки книг по жанрам, популярности, etc
*/
class CategoriesPage extends BookShelfPage {
constructor() {
super();
this.name = "categories";
this._layout.selector = ".panel-actions.pull-right button.active i";
this._layout.list = ".book-row";
this._layout.grid = ".book-shelf .bookcard";
this._layout.table = ".books-table tbody tr";
}
update() {
this._root = document.getElementById("search-results");
this._root.style.overflowX = "hidden";
super.update();
}
}
/**
* Класс для обновления страницы результатов поиска по тексту
*/
class SearchPage extends BookShelfPage {
constructor() {
super();
this.name = "search";
this._layout.selector = ".panel-actions a.active i";
this._layout.list = ".panel-body .book-row";
this._layout.grid = ".book-shelf .bookcard";
this._layout.table = ".books-table tbody tr";
this._layout.default = "grid";
}
update() {
switch ((new URL(document.location)).searchParams.get("category")) {
case "works":
this._root = document.getElementById("search-results");
this._root && (this._root.style.overflowX = "hidden");
break;
case null:
case "all":
this._root = document.querySelector("#search-results .book-shelf");
break;
default:
return;
}
super.update();
}
}
/**
* Класс для манипулящии элементами с данными пользователей
*/
class UserElement {
/**
* Конструктор
*
* @param element Element HTML-элемент пользователя
*
* @return void
*/
constructor(element) {
this.element = element;
this.nick = UserElement.userNick(element, ".card-content .user-info")[0];
}
/**
* Возвращает ники пользователей, найденные в переданном элементе без проверки на уникальность
*
* @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;
}
}
/**
* Базовый класс для манипуляции элементами книги разных видов
*/
class BookElement {
/**
* Конструктор
*
* @param element Element HTML-элемент книги
*
* @return void
*/
constructor(element) {
this.element = element;
this.authors = [];
this._fence = null;
}
/**
* Проверяет, входил ли автор в список авторов книги
*
* @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");
}
mark() {
super.mark();
this._fence.style.top = "-10px";
}
}
/**
* Класс для элемента книги в виде карточки с обложкой и краткой информацией внизу
*/
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 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();
}());