// ==UserScript== // @name AuthorTodayBlackList // @name:ru AuthorTodayBlackList // @namespace 90h.yy.zz // @version 0.7.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 // @downloadURL none // ==/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, .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 { overflow-x:hidden; }"); addStyle("tr.atbl-marked { position:relative; }"); addStyle(".book-row>.atbl-fence-block, tr>.atbl-fence-block, .profile-card>.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; } if (path.startsWith("/u/")) { if (!page || page.name !== "profile") { page = new ProfilePage(); } 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.startsWith("/top/writers") || path.startsWith("/top/users")) { if (!page || page.name !== "users") { page = new UsersPage(); } 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._requests = []; this._init(); } /** * Обновляет данные пользователя из базы данных * * @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; } 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() { return DB.updateUser(this); } /** * Удаляет пользователя из базы данных * * @return Promise Промис, гарантирующий удаление данных пользователя */ async delete() { await DB.deleteUser(this); this.notes = null; this.b_action = null; this.lastUpdate = null; } /** * Инициализация свойств начальными значениями * * @return void */ _init() { this.notes = null; this.lastUpdate = null; this.b_action = 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._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) { } } /** * Базовый класс для различных информационных блоков сайта типа книжной полки и списка авторов * На одной странице может присутствовать несколько виджетов */ class Widget { constructor(element) { this.element = element; } /** * Базовая реализация для обновления виджета * * @return void */ update() { } /** * Этот метод будет вызван в случае изменения данных какого-нибудь автора в другой вкладке * Применяется в контексте виджета * * @param nick string Ник обновленного пользователя * * @return void */ userUpdated(nick) { } } /** * Отображение обычной книжной полки с учетом раскладки */ class BookShelfWidget extends Widget { /** * Конструктор класса * * @param element Element HTML-элемент виджета * @param params object Параметры для управления виджетом */ constructor(element, params) { super(element); this._users = params.users || new UserCache(); this._watch = params.watch || false; this._layout = params.layout || {}; } /** * Обновление блока виджета * Обновление может быть как немедленное, так и с ожиданием загрузки данных в виджет * * @return void */ update() { if (!this.element) return; // Настроить скрытие плашки по клику this.element.addEventListener("click", function(event) { let fence = event.target.closest(".atbl-fence-block"); fence && fence.classList.toggle("atbl-open"); }); const ready = this._isReady(); if (ready) { // Сканировать и обновить панель с книгами this._updatePanel(); super.update(); } if (!ready || this._watch) { // Установить наблюдатель (new MutationObserver(function(mutations, observer) { if (this._isReady()) { if (!this._watch) observer.disconnect(); this._updatePanel(); } }.bind(this))).observe(this.element, { childList: true }); } } /** * Проверяет готов ли виджет для обновления * * @return string Возвращаемые значения: "waiting", "ready", "done" */ _isReady() { return !this.element.querySelector(".overlay"); } /** * Извлекает из панели список авторов, проверяет их настройки и обновляет блок с книгами * * @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 BookNarrowShelfWidget extends BookShelfWidget { _isReady() { return !this.element.querySelector(".widget-spinner"); } } /** * Виджет для отображение значка на аватаре пользователя, если это необходимо */ 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.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 (!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 UsersWidget extends Widget { constructor(element, params) { super(element); this._users = params.users || new UserCache(); } update() { // Получить список пользователей const authors = BookElement.getAuthorList(this.element); if (!authors.length) return; // Настроить скрытие плашки по клику this.element.addEventListener("click", function(event) { let fence = event.target.closest(".atbl-fence-block"); fence && fence.classList.toggle("atbl-open"); }); // Загрузить пользователей 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"); } }); } 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; } } }); } } /** * Класс для обновления страниц профиля пользователя/автора */ class ProfilePage extends Page { 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 a"); 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())); }; } /** * Привязывает пункт меню к действию по созданию и отображению диалогового окна * * @param menu_item Element HTML-элемент пункта меню для привязки * * @return void */ _bindMenuItem(menu_item) { menu_item.addEventListener("click", event => { let dlg = new ModalDialog({ mobile: false, title: "AuthorTodayBlockList - Автор", body: this._createProfileDialogContent() }); dlg.show(); dlg.element.addEventListener("submit", 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._widgets.forEach(w => w.update()); 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._widgets.forEach(w => w.update()); this._channel.postMessage(this.user.nick); dlg.hide(); Notification.display("Запись успешно удалена", "success"); }.bind(this)).catch(function(err) { Notification.display("Ошибка удаления записи", "error"); console.warn("Ошибка удаления записи: " + err.message); }); } break; } }); }); } /** * Создает 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; } } /** * Класс для отслеживания и обновления заглавной страницы сайта (8 виджетов с книгами) */ class MainPage extends Page { constructor() { super(); this.name = "main"; } update() { let users = new UserCache(); [ "mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks", "recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks" ].forEach(id => { let el = document.getElementById(id); if (el) { this._widgets.push(new BookNarrowShelfWidget(el, { users: users, watch: false, layout: { grid: ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned)", default: "grid" } })); } }); super.update(); } } /** * Класс для обновления страницы группировки книг по жанрам, популярности, etc */ class CategoriesPage extends Page { constructor() { super(); this.name = "categories"; } update() { this._widgets = []; let el = document.getElementById("search-results"); if (el) { this._widgets.push(new BookShelfWidget(el, { users: new UserCache(), 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 el = null; let layout = { grid: ".book-shelf .bookcard", default: "grid" }; switch ((new URL(document.location)).searchParams.get("category")) { case null: case "all": el = document.querySelector("#search-results .book-shelf"); break; case "works": el = document.getElementById("search-results"); el && (el.style.overflowX = "hidden"); layout.list = ".panel-body .book-row"; layout.table = ".books-table tbody tr"; layout.selector = ".panel-actions a.active i"; break; default: return; } if (el) { this._widgets.push(new BookShelfWidget(el, { users: new UserCache(), watch: true, layout: layout })); super.update(); } } } /** * Класс для обновления страницы списка пользователей/авторов */ class UsersPage extends Page { constructor() { super(); this.name = "users"; } update() { let el = document.querySelector(".panel .panel-body .flex-list"); if (!el) return; this._widgets = [ new UsersWidget(el, {}) ]; 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]; 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"); } } /** * Базовый класс для манипуляции элементами книги разных видов */ 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"); } 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(); }());