Greasy Fork

AuthorTodayBlackList

The script implements the black list of authors on the author.today website.

目前为 2023-03-16 提交的版本。查看 最新版本

// ==UserScript==
// @name           AuthorTodayBlackList
// @name:ru        AuthorTodayBlackList
// @namespace      90h.yy.zz
// @version        0.3.0
// @author         Ox90
// @match          https://author.today/*
// @description    The script implements the black list of authors on the author.today website.
// @description:ru Скрипт реализует черный список авторов на сайте author.today.
// @run-at         document-start
// @license        MIT
// ==/UserScript==

/**
 * TODO list
 * - Поменять иконку черного списка в профиле автора на что-нибудь более подходящее и заметное
 * - Сделать немедленное обновление содержимого заметки в заголовке профиля, если заметка была отредактирована в диалоговом окне
 * - Добавить возможность скрытия книг автора в виджетах, если скрытие возможно
 * - Список записей в базе данных по типу как в https://author.today/account/ignorelist
 * - Импорт/экспорт базы скрипта для переноса в другой браузер
 * - Адаптация к мобильной версии сайта
 */
 

(function start() {
  "use strict";

/**
 * Старт скрипта сразу после загрузки DOM-дерева.
 * Тут настраиваются стили, инициируется объект для текущей страницы,
 * вешается отслеживание измерений страницы скриптами сайта
 *
 * @return void
 */
function start() {
  addStyle(".atbl-badge { position: absolute; display:flex; align-items:center; justify-content:center; bottom:10px; right:10px; width:58px; height:58px; text-align:center; border:4px solid #333; border-radius:50%; background:#aaa; box-shadow:0 0 8px white; z-index:3; }");
  addStyle(".atbl-badge span { display:inline-block; color:#400; font:24px Roboto,tahoma,sans-serif; font-weight:bold; }");
  addStyle(".atbl-profile-notes { color:#fff; font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:1px 1px 3px rgba(0,0,0,.8); }");
  addStyle(".atbl-profile-notes i { margin-right:.5em; }");
  addStyle(".atbl-book-banner { position:absolute; bottom:0; left:0; right:0; height:30%; color:#fff; font-weight:bold; background-color:rgba(40,40,40,.95); border:2px ridge #666; z-index:100; }");
  addStyle(".atbl-fence-block { position:absolute; top:0; left:0; width:100%; height:100%; background-image: repeating-linear-gradient(-45deg,rgba(0,0,0,.1) 0 10px,rgba(0,0,0,.2) 10px 20px); overflow:hidden; z-index:99; cursor:pointer; transition:all 500ms cubic-bezier(.7,0,0.08,1); }");
  addStyle(".atbl-fence-block .atbl-note { display:flex; width:30%; min-height:30%; color:#fff; font-weight:bold; font-size:22px; align-items:center; justify-content:center; background-color:#282828; border:2px ridge #666; opacity:.92 }");
  addStyle(".atbl-book-banner, .atbl-fence-block { display:flex; align-items:center; justify-content:center; }");
  addStyle(".atbl-fence-block.atbl-open { left:95%; }");

  let page = null;

  function updatePageInstance() {
    let path = document.location.pathname;
    if (path === "/") {
      if (!page || page.name !== "main") {
        page = new MainPage();
      }
      return;
    }
    let res = /^\/u\/([^\/]+)/.exec(path);
    if (res) {
      let nick = res[1];
      if (!page || page.name !== "profile" || page.user.nick !== nick) {
        page = new ProfilePage(nick);
      }
      return;
    }
    res = /^\/work\/(?:genre|recommended|discounts)\//.exec(path);
    if (res) {
      if (!page || page.name !== "categories") {
        page = new CategoriesPage();
      }
      return;
    }
    page = null;
  }

  // Идентификация и обновление страницы
  updatePageInstance();
  page && page.update();

  // Отслеживание изменения контейнера на случай обновления страницы через AJAX запрос
  // Потомков не отслеживает, только изменение списка детей.
  let ajax_box = document.getElementById("pjax-container");
  if (ajax_box) {
    (new MutationObserver(function() {
      updatePageInstance();
      page && page.update();
    })).observe(ajax_box, {childList: true });
  }
}

/**
 * Создает единичный элемент типа checkbox со стилями сайта
 *
 * @param title   string Подпись для checkbox
 * @param title   string Значение атрибута name у checkbox
 * @param checked bool   Начальное состояние checkbox
 *
 * @return Element HTML-элемент для последующего добавления на форму
 */
function createCheckbox(title, name, checked) {
  let root = document.createElement("div");
  root.classList.add("checkbox", "c-checkbox", "no-fastclick");
  let label = document.createElement("label");
  root.appendChild(label);
  let input = document.createElement("input");
  input.type = "checkbox";
  input.name = name;
  checked && (input.checked = true);
  label.appendChild(input);
  let span = document.createElement("span");
  span.classList.add("icon-check-bold");
  label.appendChild(span);
  label.appendChild(document.createTextNode(title));
  return root;
}

/**
 * Создает единичный элемент select с опциями для выбора со стилями сайта
 *
 * @param name    string Имя элемента для его идентификации в DOM-дереве
 * @param options array  Массив объектов с параметрами value и text
 * @param value   string Начальное значение выбранной опции
 *
 * @return Element HTML-элемент для последующего добавления на форму
 */
function createSelectbox(name, options, value) {
  let el = document.createElement("select");
  el.classList.add("form-control");
  el.name = name;
  options.forEach(function(it) {
    let oel = document.createElement("option");
    oel.value = it.value;
    oel.textContent = it.text;
    el.appendChild(oel);
  });
  el.value = value;
  return el;
}

//----------------------------
//---------- Классы ----------
//----------------------------

/**
 * Экземпляр класса для работы с базой данных браузера (IndexedDB)
 * Все методы класса работают в асинхронном режиме
 */
let DB = new class {
  constructor() {
    this._dbh = null;
  }

  /**
   * Получение данных о пользователе по его nick, если он сохранен в базе данных
   *
   * @param user User Экземпляр класса пользователя, по которому необходимо сделать запрос
   *
   * @return Promise Промис с данными пользователя или undefined в случае отсутствия его в базе
   */
  fetchUser(user) {
    return new Promise(function(resolve, reject) {
      this._ensureOpen().then(function(dbh) {
        let req = dbh.transaction("users", "readonly").objectStore("users").get(user.nick);

        req.onsuccess = function() {
          resolve(req.result);
        }

        req.onerror = function() {
          reject(req.error);
        }
      }).catch(function(err) {
        resolve(err);
      });
    }.bind(this));
  }

  /**
   * Сохранение данных пользователя в базе данных. Если запись не существует, она будет добавлена.
   * Ключом является nick пользователя
   *
   * @param user User Экземпляр класса пользователя, данные которого нужно сохранить
   *
   * @return Promise Промис, который не возвращает никаких данных, но гарантирующий, что данные сохранены
   */
  updateUser(user) {
    return new Promise(function(resolve, reject) {
      this._ensureOpen().then(function(dbh) {
        let ct = new Date();
        let req = dbh.transaction("users", "readwrite").objectStore("users").put({
          nick: user.nick,
          fio: user.fio,
          notes: user.notes,
          b_action: user.b_action,
          lastUpdate: ct
        });

        req.onsuccess = function() {
          user.lastUpdate = ct;
          resolve();
        };
        
        req.onerror = function() {
          reject(req.error);
        };
      }).catch(function(err) {
        reject(err);
      });
    }.bind(this));
  }

  /**
   * Удаляет запись пользователя из базы данных. Ключом является nick пользователя
   *
   * @param user User Экземпляр класса пользователя, которого нужно удалить
   *
   * @return Promise Промис, который не возвращает никаких данных, но гарантирующий, что запись удалена
   */
  deleteUser(user) {
    return new Promise(function(resolve, reject) {
      this._ensureOpen().then(function(dbh) {
        let req = dbh.transaction("users", "readwrite").objectStore("users").delete(user.nick);

        req.onsuccess = function() {
          resolve();
        };

        req.onerror = function() {
          reject.req(req.error);
        };
      }).catch(function(err) {
        reject(err);
      });
    }.bind(this));
  }

  /**
   * Гарантирует соединение с базой данных
   *
   * @return Promise Промис, который возвращает объект для работы с базой данных
   */
  _ensureOpen() {
    return new Promise(function(resolve, reject) {
      if (this._dbh) {
        resolve(this._dbh);
        return;
      }

      let req = indexedDB.open("atbl_main_db", 1);

      req.onsuccess = function() {
        this._dbh = req.result;
        resolve(this._dbh);
      }.bind(this);

      req.onerror = function() {
        reject(req.error);
      };

      req.onupgradeneeded = function(event) {
        let db = req.result;
        switch (event.oldVersion) {
          case 0:
            if (!db.objectStoreNames.contains("users")) {
              db.createObjectStore("users", { keyPath: "nick" });
            }
            break;
        }
      };
    }.bind(this));
  }
}();

/**
 * Класс для работы с данными автора или пользователя.
 */
class User {
  /**
   * Конструктор класса
   *
   * @param nick string Ник пользователя для идентификации
   * @param fio  string Фамилия, имя пользователя. Или что там записано. Не обязательно.
   *
   * @return void
   */
  constructor(nick, fio) {
    this.nick = nick;
    this.fio = fio || "";
    this.notes = null;
    this.lastUpdate = null;
    this.b_action = null;
    this._requests = [];
  }

  /**
   * Обновляет данные пользователя из базы данных
   *
   * @return Promise Промис, гарантирует обновление полей пользователя
   */
  fetch() {
    if (!this._requests.length) {
      return DB.fetchUser(this).then(function(res) {
        if (res) {
          this.notes = res.notes || {};
          this.lastUpdate = res.lastUpdate;
          this.b_action = res.b_action;
          if (!this.fio) this.fio = res.fio;
        }
        this._requests.forEach(req => req.resolve());
        this._requests = [];
      }.bind(this)).catch(function(err) {
        this._requests.forEach(req =>req.reject(err));
        this._requests = [];
        throw err;
      }.bind(this));
    }

    return new Promise(function(resolve, reject) {
      this._requests.push({ resolve: resolve, reject: reject });
    }.bind(this));
  }

  /**
   * Сохраняет текущие данные пользователя в базу данных
   *
   * @return Promise Промис, гарантирует обновление данных
   */
  async save() {
    return DB.updateUser(this);
  }

  /**
   * Удаляет пользователя из базы данных
   *
   * @return Promise Промис, гарантирующий удаление данных пользователя
   */
  async delete() {
    await DB.deleteUser(this);
    this.notes = null;
    this.b_action = null;
    this.lastUpdate = null;
  }
}

/**
 * Класс для работы со списком пользователей в режиме кэша.
 * Предназначен для того, чтобы избежать дублирование запросов к базе данных.
 * Расширяет стандартный класс Map.
 */
class UserCache extends Map {
  /**
   * Асинхронный метод для получения гарантии наличия пользователей в кэше, которые, при необходимости, загружаются из БД
   *
   * @param ids array Массив идентификаторов пользователей (nick) для которых необходимы данные
   *
   * @return Promise Промис, гарантирующий, что все данные о переданных пользователях находятся в кэше
   */
  async ensure(ids) {
    let p_list = ids.reduce(function(res, id) {
      if (!this.has(id)) {
        let user = new User(id);
        this.set(id, user);
        res.push(user.fetch());
      }
      return res;
    }.bind(this), []);
    if (p_list.length) {
      await Promise.all(p_list);
    }
  }
}

/**
 * Базовый класс для работы со страницами сайта
 */
class Page {
  constructor() {
    this.name = null;
  }

  /**
   * Метод для запуска обновления страницы сайта
   */
  update() {
  }
}

/**
 * Класс с общими методами вспомогательного характера
 */
class Utils {
  /**
   * Удаляет завлекательные элементы типа "Эксклюзив", "Скидка"
   *
   * @param el Element HTML-элемент для чистки
   *
   * @return void
   */
  static removeAttraction(el) {
    [ "div.ribbon", "div.bookcard-discount" ].forEach(function(selector) {
      let e = el.querySelector(selector);
      e && e.remove();
    });
  }

  /**
   * Извлекает ники авторов из HTML-элементов
   *
   * @param el Element HTML-элемент для поиска ссылок с авторами
   *
   * @return array Массив с никами авторов
   */
  static getAuthorList(el) {
    let al = [];
    el.querySelectorAll('a[href^="/u/"]').forEach(function(ae) {
      let r = /^\/u\/([^\/]+)/.exec(ae.getAttribute("href"));
      if (r) al.push(r[1]);
    });
    return al;
  }
}

/**
 * Класс для обновления страниц профиля пользователя/автора
 */
class ProfilePage extends Page {
  /**
   * Конструктор класса
   *
   * @params nick string Ник пользователя из страницы профиля
   *
   * @return void
   */
  constructor(nick) {
    super();
    this._menu = null;
    this._badge = null;

    let fio_el = document.querySelector("h1>a[href^=\"/u/\"]");
    if (fio_el) {
      let fio = fio_el.textContent.trim();
      if (fio !== "") {
        this.user = new User(nick, fio);
        this.name = "profile";
      }
    }
  }

  /**
   * Метод для асинхронного обновления страницы.
   * - Добавляет значок на аватар пользователя, если он в черном списке
   * - Добавляет заметку, если она есть и разрешено ее отображение (только первая строчка заметки)
   * - Добавляет пункт меню в меню профиля пользователя для вызова диалога настроек
   *
   * @return void
   */
  update() {
    this.user.fetch().then(() => {
      this._updateProfileAvatar();
      this._updateProfileNotes();
      this._updateProfileMenu();
    });
  }

  /**
   * Отображение значка на аватаре пользователя, если это необходимо
   *
   * @return void
   */
  _updateProfileAvatar() {
    if (!this.user.b_action || this.user.b_action === "none") {
      if (this._badge) {
        this._badge.remove();
      }
      return;
    }

    if (!this._badge)
      this._createBadgeElement();

    if (!document.contains(this._badge)) {
      let av_el = document.querySelector("div.profile-avatar>a");
      if (av_el) {
        av_el.appendChild(this._badge);
      }
    }
  }

  /**
   * Отображение заметки в профиле пользователя, если это необходимо
   *
   * @return void
   */
  _updateProfileNotes() {
    if (this.user.notes && this.user.notes.profile && this.user.notes.text) {
      let p_info = document.querySelector("div.profile-info");
      if (p_info) {
        if (!this._notes) {
          this._notes = document.createElement("div");
          this._notes.classList.add("atbl-profile-notes");
          let ntxt = this.user.notes.text;
          let eoli = ntxt.indexOf("\n");
          if (eoli !== -1) ntxt = ntxt.substring(0, eoli).trim();
          let icon = document.createElement("i");
          icon.classList.add("icon-info-circle");
          this._notes.appendChild(icon);
          let span = document.createElement("span");
          span.appendChild(document.createTextNode(ntxt));
          this._notes.appendChild(span);
        }
        if (!p_info.contains(this._notes)) {
          p_info.appendChild(this._notes);
        }
      }
    } else if (this._notes) {
      this._notes.remove();
    }
  }

  /**
   * Добавление пункта меню для вызова диалога настроек
   *
   * @return void
   */
  _updateProfileMenu() {
    let menu_el = document.querySelector("div.cover-buttons>ul.dropdown-menu");
    if (menu_el && menu_el.children.length) {
      if (!this._menu) {
        let item = menu_el.children[0].cloneNode(true);
        let iitem = item.querySelector("i");
        let aitem = item.querySelector("a");
        let ccnt = iitem && aitem && aitem.childNodes.length || 0;
        if (ccnt >= 2) {
          iitem.setAttribute("class", "icon-pencil mr");
          iitem.setAttribute("style", "margin-right:17px !important;");
          aitem.removeAttribute("onclick");
          aitem.childNodes[ccnt - 1].textContent = "AuthorTodayBlackList (ATBL)";
          aitem.addEventListener("click", function() {
            let usr = this.user;
            let dlg = new ModalDialog({
              mobile: false,
              title: "AuthorTodayBlockList - Автор",
              body: this._createProfileDialogContent()
            });
            dlg.show();
            dlg.element.addEventListener("submit", function(event) {
              event.preventDefault();
              switch (event.submitter.name) {
                case "save":
                  this.user.b_action = dlg.element.querySelector("select[name=b_action]").value;
                  this.user.notes = {
                    text: dlg.element.querySelector("textarea[name=notes_text]").value.trim(),
                    profile: dlg.element.querySelector("input[name=notes_profile]").checked
                  };
                  this.user.save().then(function() {
                    this._updateProfileAvatar();
                    this._updateProfileNotes();
                    Notification.display("Данные успешно обновлены", "success");
                    dlg.hide();
                  }.bind(this)).catch(function(err) {
                    Notification.display("Ошибка обновления данных", "error");
                    console.warn("Ошибка обновления данных: " + err.message);
                  });
                  break;
                case "delete":
                  if (confirm("Удалить автора из базы ATBL?")) {
                    this.user.delete().then(function() {
                      this._updateProfileAvatar();
                      this._updateProfileNotes();
                      Notification.display("Запись успешно удалена", "success");
                      dlg.hide();
                    }.bind(this)).catch(function(err) {
                      Notification.display("Ошибка удаления записи", "error");
                      console.warn("Ошибка удаления записи: " + err.message);
                    });
                  }
                  break;
              }
            }.bind(this));
          }.bind(this));
          this._menu = item;
        }
      }
      if (this._menu && !menu_el.contains(this._menu)) {
        menu_el.appendChild(this._menu);
      }
    }
  }

  /**
   * Создает HTML-элемент form с полями ввода и кнопками для отображения на модальной форме в профиле пользователя
   *
   * @return Element
   */
  _createProfileDialogContent() {
    let form = document.createElement("form");
    let idiv = document.createElement("div");
    idiv.style.display = "flex";
    idiv.style.flexDirection = "column";
    idiv.style.gap = "1em";
    form.appendChild(idiv);
    let tdiv = document.createElement("div");
    tdiv.appendChild(document.createTextNode("Параметры ATBL для пользователя "));
    idiv.appendChild(tdiv);
    let ustr = document.createElement("strong");
    ustr.textContent = this.user.fio;
    tdiv.appendChild(ustr);
    let bsec = document.createElement("div");
    idiv.appendChild(bsec);
    let bttl = document.createElement("label");
    bttl.textContent = "Книги автора в виджетах";
    bsec.appendChild(bttl);
    bsec.appendChild(
      createSelectbox("b_action", [
        { value: "none", text: "Не трогать" },
        { value: "mark", text: "Помечать" }
      ], this.user.b_action || "none")
    );
    let nsec = document.createElement("div");
    idiv.appendChild(nsec);
    let nsp = document.createElement("span");
    nsp.textContent = "Заметки:";
    nsec.appendChild(nsp);
    let nta = document.createElement("textarea");
    nta.name = "notes_text";
    nta.style.width = "100%";
    nta.spellcheck = true;
    nta.maxlength = 1024;
    nta.style.minHeight = "8em";
    nta.placeholder = "Ваши заметки об авторе";
    nta.value = this.user.notes && this.user.notes.text || "";
    nsec.appendChild(nta);
    idiv.appendChild(createCheckbox(
      "Отображать заметку в профиле (только 1-я строчка)",
      "notes_profile",
      this.user.notes && this.user.notes.profile || false
    ));
    let bdiv = document.createElement("div");
    bdiv.classList.add("mt", "text-center");
    form.appendChild(bdiv);
    let btn1 = document.createElement("button");
    btn1.type = "submit";
    btn1.name = "save";
    btn1.classList.add("btn", "btn-success");
    btn1.textContent = "Обновить";
    bdiv.appendChild(btn1);
    let btn2 = document.createElement("button");
    btn2.type = "submit";
    btn2.name = "delete";
    btn2.classList.add("btn", "btn-danger", "ml");
    btn2.textContent = "Удалить запись";
    bdiv.appendChild(btn2);
    let btn3 = document.createElement("button");
    btn3.classList.add("btn", "btn-default", "atbl-btn-close", "ml");
    btn3.textContent = "Отмена";
    bdiv.appendChild(btn3);
    return form;
  }

  /**
   * Создает элемент значка для аватара автора, сообщающего, что автор находистя в ЧС
   *
   * @return Element
   */
  _createBadgeElement() {
    this._badge = document.createElement("div");
    this._badge.setAttribute("class", "atbl-badge");
    let span = document.createElement("span");
    span.appendChild(document.createTextNode("ЧС"));
    this._badge.appendChild(span);
  }
}

/**
 * Класс для отслеживания и обновления заглавной страницы сайта
 */
class MainPage extends Page {
  constructor() {
    super();
    this.name = "main";
    this._users = new UserCache();
  }

  /**
   * Метод для асинхронного обновления страницы сайта.
   * В случае, если книги подгружаются в панель отдельным запросом,
   * то на такую панель вешается наблюдатель и панель обновляется по готовности.
   *
   * @return void
   */
  update() {
    [
      "mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
      "recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
    ].forEach(function(panel_id) {
      let panel_el = document.getElementById(panel_id);
      if (panel_el)
        this._scanPanel(panel_el);
    }.bind(this));
  }

  /**
   * Сканирует указанную панель, ждет окончательную загрузку панели и запускает обновление
   *
   * @param panel_el Element HTML-элемент панели для сканирования
   *
   * @return void
   */
  _scanPanel(panel_el) {
    function getSpinner() {
      return panel_el.querySelector(".widget-spinner");
    }

    let cards = this._scanBookcards(panel_el);
    if (cards) {
      this._ensureAuthors(cards.authors).then(() => this._updateBookcovers(cards.covers, cards.authors));
    } else if (getSpinner()) {
      // Панель обновляется фоновым запросом
      // Повесить отслеживание изменений в панели
      (new MutationObserver(function(mutations, observer) {
        if (!getSpinner()) {
          observer.disconnect();
          let cards = this._scanBookcards(panel_el);
          if (cards) {
            this._ensureAuthors(cards.authors).then(() => this._updateBookcovers(cards.covers, cards.authors));
          }
        }
      }.bind(this))).observe(panel_el, { childList: true });
    }
  }

  /**
   * Убеждается, что все указанные авторы имеются в кэше
   *
   * @param authors array Массив массивов с никами авторов
   *
   * @return Promise Промис, гарантирующий наличие авторов в кэше
   */
  _ensureAuthors(authors) {
    return this._users.ensure(
      authors.reduce(function(r, a) {
        a.forEach(a2 => r.push(a2));
        return r;
      }, [])
    );
  }

  /**
   * Сканирует карточки книг в указанной панели. Возвращает объект с массивом обложек и авторов
   *
   * @param panel_el Element HTML-элемент панели с карточками книг
   *
   * @return Object Объект с полями covers и authors
   */
  _scanBookcards(panel_el) {
    let covers = [];
    panel_el.querySelectorAll(
      ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled) .book-cover"
    ).forEach(function(node) {
      covers.push(node);
    });
    if (!covers.length) return;

    let authors = [];
    // Уточнение .bookcard-footer требуется по причине того, что bookcard-authors может быть на самой обложке, когда нет изображения
    panel_el.querySelectorAll(
      ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled) .bookcard-footer>.bookcard-authors"
    ).forEach(function(node) {
      let al = Utils.getAuthorList(node);
      if (!al.length) throw new Error("У книги нет автора!");
      authors.push(al);
    });
    return { covers: covers, authors: authors };
  }

  /**
   * Обновляет обложки книг, в случае, если это необходимо
   * Если у книги несколько авторов, то затрагиваются только те книги,
   * у которых все авторы имеют необходимую пометку.
   *
   * @param bookcovers array Массив HTML-элеметов обложек
   * @param authors    array Массив массивов авторов книг
   *
   * @return void
   */
  _updateBookcovers(bookcovers, authors) {
    for (let i = 0; i < bookcovers.length; ++i) {
      let bcard = bookcovers[i];
      if (authors[i].every(id => this._users.get(id).b_action === "mark")) {
        Utils.removeAttraction(bcard);
        bcard.appendChild(this._createBannerElement());
      }
      bcard.closest(".bookcard.slick-slide").classList.add("atbl-handled");
    }
  }

  /**
   * Создает HTML-элемент баннера для обложки книги
   *
   * @return Element HTML-элемент баннера для добавление в DOM
   */
  _createBannerElement() {
    let banner = document.createElement("div");
    banner.classList.add("atbl-book-banner");
    let span = document.createElement("span");
    span.textContent = "Автор в ЧС";
    banner.appendChild(span);
    return banner;
  }
}

/**
 * Класс для обновления страницы группировки книг по жанрам, популярности, etc
 */
class CategoriesPage extends Page {
  constructor() {
    super();
    this.name = "categories";
    this._users = new UserCache();
    this._panel = null;
  }

  /**
   * Метод асинхронного обновления страницы с книгами
   * Этот тип страницы может быть обновлен тремя способами:
   * - Классически, через обновление вкладки
   * - Обновлением всего блока сайта скриптом. Например при выборе следующей страницы категории
   * - Обновлением только блока с результатами запроса. Например при выборе жанра в верхней панели.
   * Поэтому необходимо повесить дополнительный наблюдатель на панель с результами запроса
   *
   * @return void
   */
  update() {
    this._panel = document.getElementById("search-results");
    if (!this._panel) return;

    // Настроить скрытие метки по клику
    this._panel.style.overflowX = "hidden";
    this._panel.addEventListener("click", function(event) {
      let fence = event.target.closest(".atbl-fence-block");
      if (fence) fence.classList.toggle("atbl-open");
    });
    // Обновить панель
    this._updatePanel();

    // Установить наблюдатель на панель результатов
    (new MutationObserver(function() {
      if (!this._panel.querySelector(".overlay")) this._updatePanel();
    }.bind(this))).observe(this._panel, {childList: true });
  }

  /**
   * Анализирует панель блока результатов.
   *
   * @return void
   */
  _updatePanel() {
    try {
      // Искать панельки с обложками и авторами
      let covers_el = this._panel.querySelectorAll(".book-row:not(.atbl-handled)");
      let authors = [];
      this._panel.querySelectorAll(".book-row:not(.atbl-handled) .book-row-content .book-author").forEach(function(el) {
        let al = Utils.getAuthorList(el);
        if (!al.length) throw new Error("У книги нет автора!");
        authors.push(al);
      });
      if (covers_el.length === 0 || covers_el.length !== authors.length) {
        // Панельки не найдены или количество панелек с обложками и количество блоков с авторами различаются.
        if (!this._panel.querySelector(".book-row")) {
          Notification.display("Книги не найдены.", "warning");
          return;
        }
        // Все плохо. Выдать сообщение.
        throw new Error("Ошибка анализа страницы!");
      }
      // Запросить данные об авторах
      this._users.ensure(
        authors.reduce(function(r, a) {
          a.forEach(a2 => r.push(a2));
          return r;
        }, [])
      ).then(function() {
        // Начать обновлять элементы с книгами
        this._updateCovers(covers_el, authors);
      }.bind(this)).catch(function(err) {
        Notification.display(err.message, "error");
      });
    } catch(err) {
      Notification.display(err.message, "error");
      return;
    }
  }

  /**
   * Обновляет панели по списку элементов и авторов
   *
   * @param covers_el NodeList Список элементов для проверки необходимоси обновления
   * @param authors   array    Список авторов, по которому будет проверяться необходимость обновления
   *
   * @return void
   */
  _updateCovers(covers_el, authors) {
    for (let i = 0; i < covers_el.length; ++i) {
      let el = covers_el[i];
      if (authors[i].every(id => this._users.get(id).b_action === "mark")) {
        Utils.removeAttraction(el);
        this._markBookRow(el);
      }
      el.classList.add("atbl-handled");
    }
  }

  /**
   * Помечает строчку книги
   *
   * @param el Element Элемент строчки с книгой
   *
   * @return void
   */
  _markBookRow(el) {
    let fence = document.createElement("div");
    fence.classList.add("atbl-fence-block", "noselect");
    fence.style.top = "-10px";
    let note = document.createElement("div");
    note.classList.add("atbl-note");
    note.textContent = "Автор в ЧС";
    fence.appendChild(note);
    el.appendChild(fence);
  }
}

/**
 * Класс для отображения модального диалогового окна в стиле сайта
 */
class ModalDialog {
  /**
   * Конструктор
   *
   * @param params Object Объект с полями mobile (bool), title (string), body (Element)
   *
   * @return void
   */
  constructor(params) {
    this.element = null;
    this._params = params;
    this._backdrop = null;
  }

  /**
   * Отображает модальное окно
   *
   * @return void
   */
  show() {
    if (this._params.mobile) {
      this._show_m();
      return;
    }

    this.element = document.createElement("div");
    this.element.classList.add("modal", "fade", "in");
    this.element.tabIndex = -1;
    this.element.setAttribute("role", "dialog");
    this.element.style.display = "block";
    this.element.style.paddingRight = "12px";
    let dlg = document.createElement("div");
    dlg.classList.add("modal-dialog");
    dlg.setAttribute("role", "document");
    this.element.appendChild(dlg);
    let ctn = document.createElement("div");
    ctn.classList.add("modal-content");
    dlg.appendChild(ctn);
    let hdr = document.createElement("div");
    hdr.classList.add("modal-header");
    ctn.appendChild(hdr);
    let hbt = document.createElement("button");
    hbt.type = "button";
    hbt.classList.add("close", "atbl-btn-close");
    hdr.appendChild(hbt);
    let sbt = document.createElement("span");
    sbt.textContent = "x";
    hbt.appendChild(sbt);
    let htl = document.createElement("h4");
    htl.classList.add("modal-title");
    htl.textContent = this._params.title || "";
    hdr.appendChild(htl);
    let bdy = document.createElement("div");
    bdy.classList.add("modal-body");
    bdy.style.color = "#656565";
    bdy.style.minWidth = "250px";
    bdy.style.maxWidth = "max(500px,35vw)";
    bdy.appendChild(this._params.body);
    ctn.appendChild(bdy);

    this._backdrop = document.createElement("div");
    this._backdrop.classList.add("modal-backdrop", "fade", "in");

    document.body.appendChild(this.element);
    document.body.appendChild(this._backdrop);
    document.body.classList.add("modal-open");

    this.element.addEventListener("click", function(event) {
      if (event.target === this.element || event.target.closest("button.atbl-btn-close")) {
        this.hide();
      }
    }.bind(this));
    this.element.addEventListener("keydown", function(event) {
      if (event.code === "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
        this.hide();
        event.preventDefault();
      }
    }.bind(this));

    this.element.focus();
  }

  /**
   * Скрывает модальное окно и удаляет его элементы из DOM-дерева
   *
   * @return void
   */
  hide() {
    if (this._params.mobile) {
      this._hide_m();
      return;
    }

    if (this.element && this._backdrop) {
      this._backdrop.remove();
      this._backdrop = null;
      this.element.remove();
      this.element = null;
      document.body.classList.remove("modal-open");
    }
  }

  /**
   * Вариант метода show для мобильной версии сайта
   *
   * @return void
   */
  _show_m() {
    this.element = document.createElement("div");
    this.element.classList.add("popup", "popup-screen-content");
    this.element.style.overflow = "hidden";
    let ctn = document.createElement("div");
    ctn.classList.add("content-block");
    this.element.appendChild(ctn);
    let htl = document.createElement("h2");
    htl.classList.add("text-center");
    htl.textContent = this._params.title || "";
    ctn.appendChild(htl);
    let bdy = document.createElement("div");
    bdy.classList.add("modal-body");
    bdy.style.color = "#656565";
    bdy.appendChild(this._params.body);
    ctn.appendChild(bdy);
    let cbt = document.createElement("button");
    cbt.classList.add("mt", "button", "btn", "btn-default");
    cbt.textContent = "Закрыть";
    ctn.appendChild(cbt);

    cbt.addEventListener("click", function(event) {
      this._hide_m();
    }.bind(this));

    document.body.appendChild(this.element);
    this.element.style.display = "block";
    this.element.classList.add("modal-in");
    this._turnOverlay_m(true);

    this.element.focus();
  }

  /**
   * Вариант метода hide для мобильной версии сайта
   *
   * @return void
   */
  _hide_m() {
    if (this.element) {
      this.element.remove();
      this.element = null;
      this._turnOverlay_m(false);
    }
  }

  /**
   * Метод для управления положкой в мобильной версии сайта
   *
   * @param on bool Режим отображения подложки
   *
   * @return void
   */
  _turnOverlay_m(on) {
    let overlay = document.querySelector("div.popup-overlay");
    if (!overlay && on) {
      overlay = document.createElement("div");
      overlay.classList.add("popup-overlay");
      document.body.appendChild(overlay);
    }
    if (on) {
      overlay.classList.add("modal-overlay-visible");
    } else if (overlay) {
      overlay.classList.remove("modal-overlay-visible");
    }
  }
}

/**
 * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
 */
class Notification {
  /**
   * Конструктор. Вызвается из static метода display
   *
   * @param data Object Объект с полями text (string) и type (string)
   *
   * @return void
   */
  constructor(data) {
    this._data = data;
    this._element = null;
  }

  /**
   * Возвращает HTML-элемент блока с текстом уведомления
   *
   * @return Element HTML-элемент для добавление в контейнер уведомлений
   */
  element() {
    if (!this._element) {
      this._element = document.createElement("div");
      this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
      let msg = document.createElement("div");
      msg.classList.add("toast-message");
      msg.textContent = "ATBL: " + this._data.text;
      this._element.appendChild(msg);
      this._element.addEventListener("click", () => this._element.remove());
      setTimeout(() => {
        this._element.style.transition = "opacity 2s ease-in-out";
        this._element.style.opacity = "0";
        setTimeout(() => {
          let ctn = this._element.parentElement;
          this._element.remove();
          if (!ctn.childElementCount) ctn.remove();
        }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
      }, 10000); // Длительность отображения уведомления - 10 секунд
    }
    return this._element;
  }

  /**
   * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
   *
   * @param text string Текст уведомления
   * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
   *
   * @return void
   */
  static display(text, type) {
    let ctn = document.getElementById("toast-container");
    if (!ctn) {
      ctn = document.createElement("div");
      ctn.id = "toast-container";
      ctn.classList.add("toast-top-right");
      ctn.setAttribute("role", "alert");
      ctn.setAttribute("aria-live", "polite");
      document.body.appendChild(ctn);
    }
    ctn.appendChild((new Notification({ text: text, type: type })).element());
  }
}

//----------

/**
 * Добавляет стилевые блоки на страницу
 *
 * @param string css Текстовая строка CSS-блока вида ".selector1 {...} .selector2 {...}"
 *
 * @return void
 */
function addStyle(css) {
  const style = document.getElementById("atbl_stylesheet") || (function() {
    const style = document.createElement('style');
    style.type = 'text/css';
    style.id = "atbl_stylesheet";
    document.head.appendChild(style);
    return style;
  })();
  const sheet = style.sheet;
  sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}

// Проверяем доступность базы данных
if (!indexedDB) return; // База недоступна. Возможно используется приватный режим просмотра.

// Старт скрипта по готовности DOM-дерева
if (document.readyState === "loading")
  window.addEventListener("DOMContentLoaded", start);
else
  start();
}());