// ==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 // @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%; 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(); }());