AuthorTodayBlackList

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

Instalar este script¿?
Script recomendado por el autor

Puede que también te guste AuthorTodayExtractor.

Instalar este script
// ==UserScript==
// @name           AuthorTodayBlackList
// @name:ru        AuthorTodayBlackList
// @namespace      90h.yy.zz
// @version        0.14.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
 * - Поменять иконку черного списка в профиле автора на что-нибудь более подходящее и заметное
 * - Добавить возможность скрытия книг автора в виджетах, если скрытие возможно
 * - Адаптация к мобильной версии сайта
 */

(function start() {
  "use strict";

/**
 * Старт скрипта сразу после загрузки DOM-дерева.
 *
 * Тут настраиваются стили, инициируется объект для текущей страницы,
 * устанавливается отслеживание измерений страницы скриптами сайта
 * и настраивается перехват кликов по плашкам.
 *
 * @return void
 */
function start() {
  addStyle("body.atbl-debug .atbl-handled { border:1px solid red !important; }"); // Для целей отладки
  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, .atbl-button-container, .atbl-titled-element { 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, .collection-work-list, aside-widget>.panel { overflow-x:clip; }");
  addStyle("tr.atbl-marked { position:relative; }");
  addStyle(".book-row>.atbl-fence-block .atbl-note { min-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; }");
  addStyle(".atbl-tab-content { display:none; margin-top:15px; }");
  addStyle(".atbl-tab-content.active { display:block; }");
  addStyle(".atbl-table { display:table; empty-cells:show; margin:15px 0; width:100%; border:1px solid #ddd; border-radius:4px; }");
  addStyle(".atbl-table-header { display:table-header-group; font-weight:bold; background-color:#eee; }");
  addStyle(".atbl-table-body { display:table-row-group; }");
  addStyle(".atbl-table-row { display:table-row; }");
  addStyle(".atbl-table-body .atbl-table-row { cursor:pointer; }");
  addStyle(".atbl-table-body .atbl-table-row:hover { background-color:#eef; }");
  addStyle(".atbl-table-cell { display:table-cell; padding:.3em .5em; }");
  addStyle(".atbl-table-cell:not(:last-child) { border-right:1px solid #ddd; }");
  addStyle(".atbl-table-body .atbl-table-cell { border-top:1px solid #ddd; }");
  addStyle(".atbl-tab-content p { margin:0; }");
  addStyle(".atbl-tab-content legend { font-size:130%; }");
  addStyle(".atbl-tab-content fieldset form { display:flex; flex-direction:column; row-gap:1em; align-items:start; }");
  addStyle(".atbl-button-container { column-gap:16px; margin-top:24px; }");
  addStyle(".atbl-button-container button { min-width:8em; }");
  addStyle("#search-results .book-row.atbl-marked .atbl-fence-block { top:-10px; }");
  addStyle(".collection-work-list .book-row .atbl-fence-block { top:-14px; height:unset; bottom:6px; }");
  addStyle(".work-widget-list .book-row .atbl-fence-block { top:-6px; }");
  addStyle(".atbl-titled-element { column-gap:1em; }");
  addStyle(".atbl-settings-row { display:flex; column-gap:2em; flex-wrap:wrap; }");

  let page = null;

  function getPageName() {
    const path = document.location.pathname;
    if (path === "/" || path === "/what-to-listen-to") return "main";
    if (path === "/search") return "search";
    if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) return "account";
    if (path.startsWith("/u/")) {
      if ([
        "/library", "/library/reading", "/library/saved", "/library/finished", "/library/", "/library/all",
        "/library/", "/library/marks", "/library/purchased", "/library/disliked"
      ].some(ps => path.endsWith(ps))) return "library";
      return "profile";
    }
    if (path.startsWith("/work/genre/") || path.startsWith("/work/recommended/") || path.startsWith("/work/discounts/")) return "categories";
    if (path.startsWith("/top/writers") || path.startsWith("/top/users")) return "users";
    if (path.startsWith("/collection/")) return "collection";
    if ([ "/contests", "/work/", "/review/", "/post/" ].some(ps => path.startsWith(ps))) return "general";
    return "unknown";
  }

  function updatePageInstance() {
    const pname = getPageName();
    if (!page || page.name !== pname) page = Page.byName(pname);
    page && page.update();
  }

  function tuneAjaxContainer() {
    // Отслеживание изменения главного контейнера страницы сайта
    // на случай обновления страницы через AJAX запрос.
    // Все потомки не отслеживаются, только изменение списка детей.
    let ajax_box = document.getElementById("pjax-container");
    if (ajax_box) {
      (new MutationObserver(function() {
        updatePageInstance();
      })).observe(ajax_box, { childList: true });
      // Скрытие плашки по клику
      ajax_box.addEventListener("click", function(event) {
        let fence = event.target.closest(".atbl-fence-block");
        fence && fence.classList.toggle("atbl-open");
      });
    }
  }

  function updateFenceColors(color1, color2, opacity1, opacity2) {
    // Удалить старую запись стилей
    const element_id = "atbl-fence_styles";
    const css_el = document.getElementById(element_id);
    css_el && css_el.remove();
    // Создать новые стили для плашки
    let c1 = hexToRgb(color1);
    let c2 = hexToRgb(color2);
    let o1 = parseFloat(opacity1) || 0;
    let o2 = parseFloat(opacity2) || 0;
    let selector = ".atbl-fence-block";
    addStyle(
      `${selector} { background-image:repeating-linear-gradient(-45deg,rgba(${c1},${o1}) 0 10px,rgba(${c2},${o2}) 10px 20px); }`,
      element_id
    );
  }

  // Установка наблюдателя за изменением настроек оформления
  (new BroadcastChannel("colors-changed")).addEventListener("message", (message) => {
    updateFenceColors(message.data[0], message.data[1], message.data[2], message.data[3]);
  });

  // Запрос основных настроек скрипта
  (async () => {
    // Обрамление обработанных блоков на странице
    if (await DB.getSetting("debug.handled", false)) {
      document.body.classList.add("atbl-debug");
    }
    // Цвета плашки
    const color1 = await DB.getSetting("book.fence.color1", "#000000");
    const color2 = await DB.getSetting("book.fence.color2", "#000000");
    const opacity1 = await DB.getSetting("book.fence.opacity1", .3);
    const opacity2 = await DB.getSetting("book.fence.opacity2", .2);
    updateFenceColors(color1, color2, opacity1, opacity2);
    // Идентификация и обновление страницы
    updatePageInstance();
    tuneAjaxContainer();
  })();
}

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

/**
 * Класс общего назначения, для создания общих HTML-элементов
 */
class HTML {

  /**
   * Создает единичный элемент типа checkbox со стилями сайта
   *
   * @param title   string Подпись для checkbox
   * @param name    string Значение атрибута name у checkbox
   * @param checked bool   Начальное состояние checkbox
   *
   * @return Element HTML-элемент для последующего добавления на форму
   */
  static 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 = HTML.createInputElement("checkbox", 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-элемент для последующего добавления на форму
   */
  static 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;
  }

  /**
   * Создает кнопку submit с заданными свойствами со стилями сайта
   *
   * @param name string Имя кнопки
   * @param text string Текст кнопки
   * @param type string Тип кнопки
   *
   * @return Element HTML-элемент кнопки
   */
  static createSubmitButton(name, text, type) {
    if (!type) type = "default";
    let btn = document.createElement("button");
    btn.type = "submit";
    btn.name = name;
    btn.classList.add("btn", "btn-" + type);
    btn.textContent = text;
    return btn;
  }

  /**
   * Создает элемент для выбора цвета
   *
   * @param title string Надпись рядом с элементом
   * @param name  string Имя элемента
   * @param value string Предустановленный цвет в формате #xxyyzz
   *
   * @return Element HTML-элемент
   */
  static createColorPicker(title, name, value) {
    return HTML.createTitledElement(HTML.createInputElement("color", name, value), title);
  }

  /**
   * Создает элемент выбора значения из диапазона
   *
   * @param title string Надпись рядом с элементом
   * @param name  string Имя элемента
   * @param value nubmer Текущее значение
   * @param min   number Минимальное допустимое значение
   * @param max   number Максимальное допустимое значение
   *
   * @return Element HTML-элемент
   */
  static createRangeElement(title, name, value, min, max) {
    const input = HTML.createInputElement("range", name, value);
    input.min = min;
    input.max = max;
    return HTML.createTitledElement(input, title);
  }

  /**
   * Создает стандартный элемент input с указанными параметрами
   *
   * @param type  string Тип элемента
   * @param name  string Имя элемента
   * @param value mixed  Текущее значение элемента (не обязательно)
   *
   * @return Element HTML-элемент
   */
  static createInputElement(type, name, value) {
    const input = document.createElement("input");
    input.id = name;
    input.type = type;
    input.name = name;
    if (value !== undefined) input.value = value;
    return input;
  }

  /**
   * Добавляет переданному элементу текстовую надпись и возвращает новый элемент
   *
   * @param element Element HTML-элемент
   * @param title   string  Надпись для элемента
   *
   * @return Element HTML-элемент
   */
  static createTitledElement(element, title) {
    const div = document.createElement("div");
    div.classList.add("atbl-titled-element");
    div.appendChild(element);
    const label = document.createElement("label");
    label.setAttribute("for", element.id);
    label.textContent = title;
    div.appendChild(label);
    return div;
  }
}

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

  /**
   * Получение общего количества записей о пользователях
   *
   * @return Promise Промис с количеством записей
   */
  usersCount() {
    return new Promise(function(resolve, reject) {
      this._ensureOpen().then(function(dbh) {
        let req = dbh.transaction("users", "readonly").objectStore("users").count();

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

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

  /**
   * Получение данных о пользователе по его 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) {
        reject(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));
  }

  /**
   * Возвращает список пользователей из базы данных
   *
   * @param count  number Максимальное количество записей. По умолчанию: 20. Отрицательное - без ограничений
   * @param offset number Сколько записей нужно пропустить перед началом выборки. По умолчанию: 0
   *
   * @return Promise Промис результатом
   */
  usersList(count, offset) {
    return new Promise(function(resolve, reject) {
      this._ensureOpen().then(function(dbh) {
        let res = [];
        let offs = 0;
        let cursor = null;
        if (!count) count = 20;

        let req = dbh.transaction("users", "readonly").objectStore("users").openCursor();

        req.addEventListener("success", event => {
          let cursor = event.target.result;
          if (!cursor) {
            resolve(res);
            return;
          }
          if (offset) {
            offs = offset;
            offset = 0;
          } else {
            res.push(cursor.value);
            if (!(--count)) {
              resolve(res);
              return;
            }
          }
          if (offs) cursor.advance(offs);
          else cursor.continue();
          offs = 0;
        });

        req.addEventListener("error", err => {
          reject(err);
        });
      }).catch(function(err) {
        reject(err);
      });
    }.bind(this));
  }

  /**
   * Читает значение настроек из базы данных
   *
   * @param key    string Имя настройки
   * @param defval mixed  Будет возвращено, если запись отсутствует
   *
   * @return Promise Промис со значением
   */
  getSetting(key, defval) {
    return new Promise((resolve, reject) => {
      this._ensureOpen().then(dbh => {
        let req = dbh.transaction("settings", "readonly").objectStore("settings").get(key);


        req.addEventListener("success", event => {
          let res = event.target.result;
          resolve(res !== undefined ? res : defval);
        });

        req.addEventListener("error", err => reject(err));
      }).catch(err => {
        reject(err);
      });
    });
  }

  /**
   * Сохраняет настройку в базе данных
   *
   * @param key   string Имя настройки
   * @param value mixed  Значение настройки
   *
   * @return Promise Промис что все сохранено
   */
  updateSetting(key, value) {
    return new Promise((resolve, reject) => {
      this._ensureOpen().then(dbh => {
        let req = dbh.transaction("settings", "readwrite").objectStore("settings").put(value, key);

        req.addEventListener("success", event => resolve());
        req.addEventListener("error", err => resolve(err));
      }).catch(err => {
        reject(err);
      });
    });
  }

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

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

      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;
        if (!db.objectStoreNames.contains("users")) {
          db.createObjectStore("users", { keyPath: "nick" });
        }
        if (!db.objectStoreNames.contains("settings")) {
          db.createObjectStore("settings");
        }
      };

      req.onblocked = function() {
        Notification.display("Обновление базы данных скрипта блокируется другой вкладкой", "warning");
      };
    }.bind(this));
  }
}();

/**
 * Класс для работы с данными автора или пользователя.
 */
class User {

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

  /**
   * Обновляет данные пользователя из базы данных
   *
   * @return Promise Промис, гарантирует обновление полей пользователя
   */
  fetch() {
    if (!this._requests.length) {
      return DB.fetchUser(this).then(function(res) {
        if (res) {
          this._fillData(res);
        } 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() {
    await DB.updateUser(this);
    this.empty = false;
  }

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

  /**
   * Возвращает первую строчку заметки с вырезанными пробельными символами слева и справа
   *
   * @param note string Оригинальная строка заметки
   *
   * @return string
   */
  shortNote() {
    if (!this.notes || !this.notes.text) return;
    let ntxt = this.notes.text;
    let eoli = ntxt.indexOf("\n");
    if (eoli !== -1) ntxt = ntxt.substring(0, eoli).trim();
    return ntxt;
  }

  /**
   * Создает экземпляр класса User по переданным данным
   *
   * @param data object Данные пользователя
   *
   * @return User
   */
  static fromObject(data) {
    let usr = new User(data.nick);
    usr._fillData(data);
    return usr;
  }

  /**
   * Инициализация свойств начальными значениями
   *
   * @return void
   */
  _init() {
    this.notes = null;
    this.b_action = null;
    this.lastUpdate = null;
    this.empty = true;
  }

  /**
   * Заполняет экземпляр с переданных данных
   *
   * @param data object Объект с данными, обычно из БД
   *
   * @return void
   */
  _fillData(data) {
    this.notes = data.notes || {};
    this.lastUpdate = data.lastUpdate;
    this.b_action = data.b_action;
    if (!this.fio) this.fio = data.fio;
    this.empty = false;
  }
}

/**
 * Класс реализует список пользователей
 */
class UserList extends Array {

  /**
   * Получает список пользователей из базы данных и возвращает экземпляр списка
   *
   * @param count  number Максимальное количество записей. Необязательное значение. По умолчанию: 20.
   * @param offset number Смещение начала выборки записей. Необязательное значение. По умолчанию: 0.
   *
   * @return Promise Промис с экземпляром списка пользователей
   */
  static async fetch(count, offset) {
    let res = new UserList();
    await DB.usersList(count, offset).then(list => {
      list.forEach(d => res.push(User.fromObject(d)));
    });
    return res;
  }
}

/**
 * Класс для работы со списком пользователей в режиме кэша.
 * Предназначен для того, чтобы избежать дублирование запросов к базе данных.
 * Расширяет стандартный класс 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.users = new UserCache();
    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) {
  }

  /**
   * Метод-фабрика для получения экземпляра страницы по ее имени
   *
   * @param name string Имя страницы
   *
   * @return Page
   */
  static byName(name) {
    switch (name) {
      case "main":
        return new MainPage();
      case "account":
        return new AccountPage();
      case "profile":
        return new ProfilePage();
      case "categories":
        return new CategoriesPage();
      case "users":
        return new UsersPage();
      case "search":
        return new SearchPage();
      case "collection":
        return new CollectionPage();
      case "general":
        return new GeneralPage();
      case "library":
        return new LibraryPage();
    }
  }
}

/**
 * Базовый класс для различных информационных блоков сайта типа книжной полки и списка авторов
 * На одной странице может присутствовать несколько виджетов
 */
class Widget {
  constructor(element) {
    this.element = element;
  }

  /**
   * Базовая реализация для обновления виджета
   *
   * @return void
   */
  update() {
  }

  /**
   * Этот метод будет вызван в случае изменения данных какого-нибудь автора в другой вкладке
   * Применяется в контексте виджета
   *
   * @param nick string Ник обновленного пользователя
   *
   * @return void
   */
  userUpdated(nick) {
  }
}

/**
 * Базовая реализация класса виджета, контейнер которого может быть обновлен отдельно,
 * через отдельный AJAX запрос. Обычно такое происходит при смене параметров фильтра.
 */
class AjaxWidget extends Widget {

  /**
   * Конструктор класса
   *
   * @param element Element HTML-элемент контейнера виджета
   * @param watch   bool    Нужно ли оставлять наблюдатель после первой загрузки данных
   * @param deep    bool    Нужно ли отслеживать потомков
   *
   * return void
   */
  constructor(element, watch, deep) {
    super(element);
    this._watch = watch;
    this._deep = deep;
    this._observed = false;
  }

  /**
   * Во время обновления виджета при необходимости на основной элемент вешается наблюдатель
   * Будет ли он висеть постоянно или будет отключен после заполнения виджета,
   * зависит от флага watch, переданного в конструктор
   *
   * @return void
   */
  update() {
    const ready = this._isReady();
    if (ready) {
      // Сканировать и обновить панель
      this._updatePanel();
      super.update();
    }
    if (!this._observed && (!ready || this._watch)) {
      // Установить наблюдатель
      this._observed = true;
      (new MutationObserver(function(mutations, observer) {
        if (this._isReady()) {
          if (!this._watch) observer.disconnect();
          this._updatePanel();
        }
      }.bind(this))).observe(this.element, { childList: true, subtree: this._deep });
    }
  }

  /**
   * Проверяет готов ли виджет для обновления
   *
   * @return bool
   */
  _isReady() {
    return !this.element.querySelector(".overlay");
  }

  /**
   * Код для фактическогое обновления виджета в результате вызова метода update
   * или срабатывания наблюдателя в момент завершения заполнения виджета данными
   *
   * @return void
   */
  _updatePanel() {
  }
}

/**
 * Реализация AJAX виджета-контейнера, который сам содержит виджеты
 */
class AjaxContainer extends AjaxWidget {

  /**
   * Конструктор класса
   *
   * @param element Element HTML-элемент контейнера виджета
   * @param wparams Array   Массив пар виджет-селектор, которые будут отображаться в контейнере
   *
   * @return void
   */
  constructor(element, wparams) {
    super(element, true, false);
    this._widgets = [];
    this._selectors = [];
    wparams.forEach(it => {
      this._widgets.push(it[0]);
      this._selectors.push(it[1]);
    });
  }

  userUpdated(nick) {
    this._widgets.forEach(w => w.userUpdated(nick));
  }

  _isReady() {
    return true;
  }

  _updatePanel() {
    this._widgets.forEach((w, i) => {
      w.element = this.element.querySelector(this._selectors[i]);
      w.element && w.update();
    });
  }
}

/**
 * Отображение обычной книжной полки с учетом раскладки
 */
class BookShelfWidget extends AjaxWidget {

  /**
   * Конструктор класса
   *
   * @param element Element HTML-элемент виджета
   * @param params  object  Параметры для управления виджетом
   */
  constructor(element, params) {
    super(element, params.watch || false, false);
    this._users = params.users || new UserCache();
    this._layout = params.layout || {};
  }

  /**
   * Извлекает из панели список авторов, проверяет их настройки и обновляет блок с книгами
   *
   * @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 SpinnerBookShelfWidget extends BookShelfWidget {
  _isReady() {
    return !this.element.querySelector(".widget-spinner");
  }
}

/**
 * Книжная полка в боковом виджете (рекомендации, скидки и т.п.)
 */
class AsideBookShelfWidget extends SpinnerBookShelfWidget {
  constructor(element, params) {
    super(element, params);
    this._deep = true;
  }

  _isReady() {
    return this.element.children.length && super._isReady() || false;
  }
}

/**
 * Виджет для отображение значка на аватаре пользователя, если это необходимо
 */
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.shortNote();
      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 UserCardWidget extends Widget {

  /**
   * Конструктор
   *
   * @param element Element HTML-элемент карточки
   * @param params  Object  Параметры виджета
   *
   * @return void
   */
  constructor(element, params) {
    super(element);
    this._users = params.users || new UserCache();
    this._card = new UserAsideElement(element);
  }

  update() {
    let nick = this._card.nick;
    if (!nick) return;
    this._users.ensure([ nick ]).then(() => {
      try {
        if (!this.element.classList.contains("atbl-handled")) {
          if (this._users.get(nick).b_action === "mark") {
            this._card.mark();
          }
          this.element.classList.add("atbl-handled");
        }
      } catch (err) {
        Notification.display(err.message, "error");
      }
    });
  }

  userUpdated(nick) {
    if (nick !== this._card.nick || !this.element.classList.contains("atbl-handled")) return;
    const user = this._users.get(nick);
    if (!user) return;
    user.fetch().then(() => {
      const card = new UserAsideElement(this.element);
      if (user.b_action === "mark") card.mark();
        else card.unmark();
    });
  }
}

/**
 * Виджет отображения списка пользователей в виде карточек
 */
class UsersWidget extends AjaxWidget {
  constructor(element, params) {
    super(element, params.watch, false);
    this._users = params.users || new UserCache();
  }

  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;
        }
      }
    });
  }

  _updatePanel() {
    // Получить список пользователей
    const authors = BookElement.getAuthorList(this.element);
    if (!authors.length) return;

    // Загрузить пользователей
    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");
      }
    });
  }
}

/**
 * Виджет для отображения параметров скрипта в настройках акканута
 */
class SettingsWidget extends Widget {
  constructor(element, channel) {
    super(element);
    this._channel = channel;
  }

  update() {
    if (this.element.classList.contains("atbl-handled")) return;
    // Создать основную панель
    let panel = document.createElement("div");
    panel.classList.add("panel", "panel-default");
    this.element.appendChild(panel);
    let header = document.createElement("div");
    header.classList.add("panel-heading");
    header.textContent = "Параметры скрипта AuthorTodayBlackList";
    panel.appendChild(header);
    let body = document.createElement("div");
    body.classList.add("panel-body");
    panel.appendChild(body);
    // Создать вкладки
    this._makeTabs(body);
    // Создать раздел настроек
    this._makeSettings(body);
    // Создать раздел со списком пользователей
    this._makeAuthorList(body);
    // Создать раздел импорта/экспорта данных
    this._makeImportExport(body);
    // Обработано
    this.element.classList.add("atbl-handled");
    // Активировать первую вкладку
    body.querySelector("ul.nav>li").dispatchEvent(new Event("click", { bubbles: true }));
  }

  userUpdated(nick) {
    let table = this.element.querySelector("div[data-name=authors] .atbl-table");
    table && table.dispatchEvent(new CustomEvent("user-updated", { detail: nick }));
  }

  /**
   * Создает элемент для отображения разделов параметров скрипта, со стилями оригинального сайта
   *
   * @param body_el Element HTML-элемент который станет родительским
   *
   * @return void
   */
  _makeTabs(body_el) {
    let div1 = document.createElement("div");
    div1.classList.add("panel-heading", "panel-nav");
    body_el.appendChild(div1);
    let div2 = document.createElement("div");
    div2.classList.add("nav-pills-v2");
    div1.appendChild(div2);
    let ul = document.createElement("ul");
    ul.classList.add("nav", "nav-pills", "pill-left", "ml-lg");
    div2.appendChild(ul);
    [
      [ "Настройки", "settings" ],
      [ "Список авторов", "authors" ],
      [ "Импорт/Экспорт", "imp-exp" ]
    ].forEach(it => {
      let li = document.createElement("li");
      li.dataset.target = it[1];
      ul.appendChild(li);
      let ae = document.createElement("a");
      ae.classList.add("nav-link");
      ae.setAttribute("href", "");
      ae.textContent = it[0];
      li.appendChild(ae);
    });
    ul.addEventListener("click", event => {
      let li = event.target.closest("li[data-target]");
      if (li) {
        event.preventDefault();
        ul.querySelectorAll("li[data-target]").forEach(ie => {
          let act = null;
          if (li === ie) {
            if (!ie.classList.contains("active")) act = "add";
          } else if (ie.classList.contains("active")) {
            act = "remove";
          }
          if (act) {
            ie.classList[act]("active");
            let tc = body_el.querySelector(".atbl-tab-content[data-name=" + ie.dataset.target + "]");
            tc && tc.classList[act]("active");
          }
        });
      }
    });
  }

  /**
   * Раздел с настройками скрипта
   *
   * @param body_el Element HTML-элемент виджета, где будет отображен раздел
   *
   * @return void
   */
  _makeSettings(body_el) {
    let div1 = document.createElement("div");
    div1.classList.add("atbl-tab-content");
    div1.dataset.name = "settings";
    body_el.appendChild(div1);
    this._makeDecorSettings(div1);
    this._makeDialogSettings(div1);
    this._makeDebugSettings(div1);
  }

  /**
   * Подраздел с настройками оформления
   *
   * @param parent Element Родительский элемент
   *
   * @return void
   */
  _makeDecorSettings(parent) {
    let fset = document.createElement("fieldset");
    parent.appendChild(fset);
    let lg = document.createElement("legend");
    lg.textContent = "Внешний вид";
    fset.appendChild(lg);
    let form = document.createElement("form");
    form.method = "post";
    fset.appendChild(form);
    let subtitle = document.createElement("b");
    subtitle.textContent = "Закрывающая плашка";
    form.appendChild(subtitle);
    let content = document.createElement("div");
    content.style.position = "relative";
    content.style.display = "flex";
    content.style.flexDirection = "column";
    content.style.alignItems = "center";
    content.style.justifyContent = "center";
    content.style.minWidth = "10em";
    content.style.minHeight = "6em";
    content.innerHTML =
      '<div style="color:#444;">Название книги</div><div style="color:#4582af;">Ссылка</div><div style="color:#9a938d">Авторы</div>';
    form.appendChild(content);
    let fence = document.createElement("div");
    fence.style.position = "absolute";
    fence.style.top = 0;
    fence.style.left = 0;
    fence.style.right = 0;
    fence.style.bottom = 0;
    content.appendChild(fence);
    subtitle = document.createElement("b");
    subtitle.textContent = "Настройки основного цвета закрывающей плашки";
    form.appendChild(subtitle);
    let row = document.createElement("div");
    row.classList.add("atbl-settings-row");
    form.appendChild(row);
    let color1 = HTML.createColorPicker("Значение", "atbl-fence-color1", "#000000");
    row.appendChild(color1);
    color1 = color1.querySelector("input");
    let transp1 = HTML.createRangeElement("Прозрачность", "atbl-fence-transp1", 70, 0, 100);
    row.appendChild(transp1);
    transp1 = transp1.querySelector("input");
    subtitle = document.createElement("b");
    subtitle.textContent = "Настройки дополнительного цвета закрывающей плашки";
    form.appendChild(subtitle);
    row = row.cloneNode(false);
    form.appendChild(row);
    let color2 = HTML.createColorPicker("Значение", "atbl-fence-color2", "#000000");
    row.appendChild(color2);
    color2 = color2.querySelector("input");
    let transp2 = HTML.createRangeElement("Прозрачность", "atbl-fence-transp2", 80, 0, 100);
    row.appendChild(transp2);
    transp2 = transp2.querySelector("input");
    let sbtn = HTML.createSubmitButton("save", "Сохранить", "primary");
    form.appendChild(sbtn);

    function updateFence() {
      const c1 = hexToRgb(color1.value);
      const o1 = (100 - parseInt(transp1.value)) / 100;
      const c2 = hexToRgb(color2.value);
      const o2 = (100 - parseInt(transp2.value)) / 100;
      fence.style.backgroundImage = `repeating-linear-gradient(-45deg,rgba(${c1},${o1}) 0 10px,rgba(${c2},${o2}) 10px 20px)`;
    }

    const watched_controls = [ color1, color2, transp1, transp2 ];
    form.addEventListener("change", event => {
      if (!watched_controls.includes(event.target)) return;
      updateFence();
    });

    form.addEventListener("submit", event => {
      event.preventDefault();
      fset.disabled = true;
      (async () => {
        try {
          const c1 = color1.value;
          const c2 = color2.value;
          const o1 = (100 - parseInt(transp1.value)) / 100;
          const o2 = (100 - parseInt(transp2.value)) / 100;
          await DB.updateSetting("book.fence.color1", c1);
          await DB.updateSetting("book.fence.color2", c2);
          await DB.updateSetting("book.fence.opacity1", o1);
          await DB.updateSetting("book.fence.opacity2", o2);
          (new BroadcastChannel("colors-changed")).postMessage([ c1, c2, o1, o2 ]);
          Notification.display("Настройки оформления успешно сохранены", "success");
        } catch (err) {
          Notification.display("Ошибка сохранения настроек оформления", "error");
          console.error(err);
        } finally {
          fset.disabled = false;
        }
      })();
    });

    fset.disabled = true;

    (async () => {
      try {
        color1.value = await DB.getSetting("book.fence.color1", "#000000");
        color2.value = await DB.getSetting("book.fence.color2", "#000000");
        transp1.value = Math.round((1 - await DB.getSetting("book.fence.opacity1", .2)) * 100);
        transp2.value = Math.round((1 - await DB.getSetting("book.fence.opacity2", .3)) * 100);
        updateFence();
        fset.disabled = false;
      } catch(err) {
        Notification.display(err.message, "error");
      }
    })();
  }

  /**
   * Подраздел с настройками формы для добавления автора
   *
   * @param parent Element Родительский элемент
   *
   * @return void
   */
  _makeDialogSettings(parent) {
    let dlg_fset = document.createElement("fieldset");
    parent.appendChild(dlg_fset);
    let lg = document.createElement("legend");
    lg.textContent = "Добавление автора";
    dlg_fset.appendChild(lg);
    let dlg_form = document.createElement("form");
    dlg_form.method = "post";
    dlg_fset.appendChild(dlg_form);
    let note = document.createElement("p");
    note.textContent =
      "Изначальные значения элементов формы в диалоге добавления нового автора." +
      " Может быть удобно для быстрого наполнения списка авторов.";
    dlg_form.appendChild(note);
    let div2 = document.createElement("div");
    div2.style.maxWidth = "20em";
    dlg_form.appendChild(div2);
    let lb = document.createElement("label");
    lb.textContent = "Книги автора в виджетах";
    div2.appendChild(lb);
    let b_ac = HTML.createSelectbox("b_action", [
      { value: "none", text: "Не трогать" },
      { value: "mark", text: "Помечать" }
    ], "none" || "none");
    div2.appendChild(b_ac);
    let n_pr = HTML.createCheckbox("Отображать короткую заметку в профиле", "notes_profile", false);
    dlg_form.appendChild(n_pr);
    let d_ht = HTML.createCheckbox("Отображать подсказку в окне диалога", "dialog_hint", true);
    dlg_form.appendChild(d_ht);

    let btn1 = HTML.createSubmitButton("save", "Сохранить", "primary");
    dlg_form.appendChild(btn1);

    dlg_form.addEventListener("submit", event => {
      event.preventDefault();
      dlg_fset.disabled = true;
      (async () => {
        try {
          await DB.updateSetting("authors.dialog.b_action", b_ac.value);
          await DB.updateSetting("authors.dialog.notes_profile", n_pr.querySelector("input").checked);
          await DB.updateSetting("authors.dialog.hint", d_ht.querySelector("input").checked);
          Notification.display("Настройки успешно сохранены", "success");
        } catch(err) {
          Notification.display("Ошибка сохранения настроек", "error");
          console.error(err);
        } finally {
          dlg_fset.disabled = false;
        }
      })();
    });

    dlg_fset.disabled = true;

    (async () => {
      try {
        b_ac.value = await DB.getSetting("authors.dialog.b_action", "none");
        n_pr.querySelector("input").checked = await DB.getSetting("authors.dialog.notes_profile", false);
        d_ht.querySelector("input").checked = await DB.getSetting("authors.dialog.hint", true);
        dlg_fset.disabled = false;
      } catch(err) {
        Notification.display(err.message, "error");
      }
    })();
  }

  /**
   * Раздел для управления отладкой
   *
   * @param Element Родительский элемент
   *
   * @return void
   */
  _makeDebugSettings(parent) {
    let dlg_fset = document.createElement("fieldset");
    parent.appendChild(dlg_fset);
    let lg = document.createElement("legend");
    lg.textContent = "Режим отладки";
    dlg_fset.appendChild(lg);
    let form = document.createElement("form");
    form.method = "post";
    dlg_fset.appendChild(form);
    let note = document.createElement("p");
    note.textContent =
      "Опции, необходимые для разработки и отладки скрипта." +
      " Если вы обычный пользователь, то скорее всего, они вам не нужны.";
    form.appendChild(note);
    let dhd = HTML.createCheckbox("Обрамлять обработанные блоки", "debug-handled", false);
    form.appendChild(dhd);
    let sbtn = HTML.createSubmitButton("save", "Сохранить", "primary");
    form.appendChild(sbtn);

    form.addEventListener("submit", event => {
      event.preventDefault();
      dlg_fset.disabled = true;
      (async () => {
        try {
          await DB.updateSetting("debug.handled", dhd.querySelector("input").checked);
          Notification.display("Опции отладки успешно изменены", "success");
        } catch (err) {
          Notification.display("Ошибка изменения отладочных опций", "error");
          console.error(err);
        } finally {
          dlg_fset.disabled = false;
        }
      })();
    });

    dlg_fset.disabled = true;
    (async () => {
      try {
        dhd.querySelector("input").checked = await DB.getSetting("debug.handled", false);
        dlg_fset.disabled = false;
      } catch (err) {
        Notification.display(err.message, "error");
      }
    })();
  }

  /**
   * Раздел со списком авторов
   *
   * @param body_el Element HTML-элемент виджета, где будет отображен раздел
   *
   * @return void
   */
  _makeAuthorList(body_el) {
    let div1 = document.createElement("div");
    div1.classList.add("atbl-tab-content");
    div1.dataset.name = "authors";
    body_el.appendChild(div1);

    let info_el = document.createElement("div");
    info_el.appendChild(document.createTextNode("Записи: "));
    let from_el = document.createElement("b");
    info_el.appendChild(from_el);
    info_el.appendChild(document.createTextNode(" - "));
    let to_el = document.createElement("b");
    info_el.appendChild(to_el);
    info_el.appendChild(document.createTextNode(" из "));
    let cnt_el = document.createElement("b");
    info_el.appendChild(cnt_el);
    div1.appendChild(info_el);

    let table = this._usersTableElement();
    div1.appendChild(table);
    let tbody = table.querySelector("div.atbl-table-body");

    let form = document.createElement("form");
    form.style.display = "flex";
    form.style.justifyContent = "space-between";
    div1.appendChild(form);

    let btn1 = HTML.createSubmitButton("prev", "Назад");
    form.appendChild(btn1);
    let btn2 = HTML.createSubmitButton("next", "Далее");
    form.appendChild(btn2);

    const pageSize = 20;
    let records = 0;
    let recFrom = 0;
    let recTo = 0;

    async function updateCount() {
      records = await DB.usersCount();
      cnt_el.textContent = records;
    }

    function updateRange() {
      from_el.textContent = recFrom;
      to_el.textContent = recTo;
    }

    function insertUserRow(user) {
      let row = document.createElement("div");
      row.classList.add("atbl-table-row");
      row.appendChild(makeTableCell(user.nick));
      row.appendChild(makeTableCell(user.fio));
      row.appendChild(makeTableCell(user.shortNote()));
      row.dataset.id = user.nick;
      tbody.appendChild(row);
    }

    function makeTableCell(value) {
      let el = document.createElement("div");
      el.classList.add("atbl-table-cell");
      if (value) el.textContent = value;
      return el;
    }

    function enableButtons(enable) {
      btn1.disabled = !enable || recFrom === 1;
      btn2.disabled = !enable || recTo >= records;
    }

    function loadData() {
      if (!recFrom) recFrom = 1;
      if (recFrom > records) recFrom = Math.max(records - pageSize + 1, 1);
      UserList.fetch(pageSize, recFrom - 1).then(list => {
        while (tbody.firstChild) tbody.lastChild.remove();
        list.forEach(usr => insertUserRow(usr));
        recTo = recFrom + list.length - 1;
        if (recFrom > recTo) recFrom = recTo;
        updateRange();
        updateCount().then(() => enableButtons(true));
      }).catch(err => Notification.display(err.message, "error"));
    }

    form.addEventListener("submit", event => {
      event.preventDefault();
      enableButtons(false);
      if (event.submitter.name === "next") {
        recFrom = recTo + 1;
      } else {
        recFrom = Math.max(recFrom - pageSize, 1);
      }
      loadData();
    });

    tbody.addEventListener("click", event => {
      let row = event.target.closest(".atbl-table-row");
      if (row && row.dataset.id) {
        let user = new User(row.dataset.id);
        user.fetch().then(() => {
          let dlg = new UserModalDialog(user, {
            mobile: false,
            title: "AuthorTodayBlockList - Автор"
          });
          dlg.show();
          dlg.element.addEventListener("submitted", () => {
            this._channel.postMessage(user.nick);
            dlg.hide();
            updateCount().then(() => {
              updateRange();
              loadData();
              enableButtons(true);
            });
          });
        });
      }
    });

    table.addEventListener("user-updated", event => {
      updateCount().then(() => loadData());
    });

    enableButtons(false);
    updateCount().then(() => {
      updateRange();
      loadData();
    });
  }

  /**
   * Раздел для управления импортом и экспортом
   *
   * @param body_el Element HTML-элемент виджета, где будет отображен раздел
   *
   * @return void
   */
  _makeImportExport(body_el) {
    let div1 = document.createElement("div");
    div1.classList.add("atbl-tab-content");
    div1.dataset.name = "imp-exp";
    body_el.appendChild(div1);

    let exp_fset = document.createElement("fieldset");
    div1.appendChild(exp_fset);
    let lg = document.createElement("legend");
    lg.textContent = "Экспорт данных";
    exp_fset.appendChild(lg);
    let exp_form = document.createElement("form");
    exp_form.method = "post";
    exp_fset.appendChild(exp_form);
    let note = document.createElement("p");
    note.textContent =
      "Экспорт данных скрипта из внутреннего хранилища браузера для переноса в другой браузер или в целях" +
      " создания страховой копии. Обратите внимание: никакие ваши персональные данные, в том числе данные" +
      " вашего аккаунта, в указанный файл не выгружаются.";
    exp_form.appendChild(note);
    let exp_btn = HTML.createSubmitButton("export", "Экспорт", "primary");
    exp_form.appendChild(exp_btn);

    let imp_fset = document.createElement("fieldset");
    div1.appendChild(imp_fset);
    lg = document.createElement("legend");
    lg.textContent = "Импорт данных";
    imp_fset.appendChild(lg);
    let imp_form = document.createElement("form");
    imp_form.method = "post";
    imp_form.enctype = "multipart/form-data";
    imp_fset.appendChild(imp_form);
    note = document.createElement("p");
    note.textContent =
      "Импорт данных скрипта из указанного файла. Во время импорта авторы не удаляются, а только" +
      " добавляются и обновляются." +
      " Внимание: Если автор уже существует, то его данные будут переписаны данными из файла.";
    imp_form.appendChild(note);
    let imp_file = document.createElement("input");
    imp_file.type = "file";
    imp_file.required = true;
    imp_form.appendChild(imp_file);
    let imp_btn = HTML.createSubmitButton("import", "Импорт", "danger");
    imp_btn.disabled = true;
    imp_form.appendChild(imp_btn);

    function enableForms(enable) {
      exp_fset.disabled = !enable;
      imp_fset.disabled = !enable;
    }

    exp_form.addEventListener("submit", event => {
      event.preventDefault();
      enableForms(false);
      this._exportData().catch(err => {
        Notification.display(err.message, "error");
      }).finally(() => {
        enableForms(true)
      });
    });

    imp_file.addEventListener("change", () => (imp_btn.disabled = !imp_file.files.length));

    imp_form.addEventListener("submit", event => {
      event.preventDefault();
      enableForms(false);
      this._importData(imp_file.files[0]).then(() => {
        Notification.display("Данные успешно загружены");
        imp_form.reset();
        imp_btn.disabled = true;
        (new BroadcastChannel("user-updated")).postMessage("*");
      }).catch(err => {
        Notification.display(err.message, "error");
      }).finally(() => {
        enableForms(true)
      });
    });
  }

  /**
   * Создает пустую таблицу для отображения пользователей
   *
   * @return Element HTML элемент таблицы
   */
  _usersTableElement() {
    let table = document.createElement("div");
    table.classList.add("atbl-table");
    let hdr = document.createElement("div");
    hdr.classList.add("atbl-table-header");
    table.appendChild(hdr);
    let row = document.createElement("div");
    row.classList.add("atbl-table-row");
    hdr.appendChild(row);
    [ "Ник", "Имя", "Комментарий" ].forEach(title => {
      let tc = document.createElement("div");
      tc.classList.add("atbl-table-cell");
      tc.textContent = title;
      row.appendChild(tc);
    });
    let body = document.createElement("div");
    body.classList.add("atbl-table-body");
    table.appendChild(body);
    return table;
  }

  /**
   * Экспортирует данные и базы данных в файл json
   *
   * @return Promise с количеством записей
   */
  async _exportData() {
    try {
      let list = await DB.usersList(-1);
      let data = JSON.stringify({
        type: "atbl-data",
        version: 1,
        users: list
      }, undefined, 1);
      let link = document.createElement("a");
      link.download = "AuthorTodayBlackList-Export-" + Math.floor(Date.now() / 1000) + ".json";
      link.href = URL.createObjectURL(new Blob([ data ], { type: "application/json" }));
      link.click();
      URL.revokeObjectURL(link.href);
      return list.length;
    } catch(err) {
      Notification.display(err.message, "error");
    }
  }

  /**
   * Импортирует данные в базу данных из файла json
   *
   * @param file File Файл из HTML формы
   *
   * @return Promise с количеством записей
   */
  _importData(file) {
    return new Promise((resolve, reject) => {
      let reader = new FileReader();
      reader.readAsText(file);
      reader.addEventListener("load", () => {
        try {
          let data = null;
          try {
            data = JSON.parse(reader.result, (key, value) => {
              if (key === "lastUpdate") return new Date(value);
              return value;
            });
          } catch(err) {
            throw new Error("Некорректный JSON файл");
          }
          if (data.type !== "atbl-data" || data.version !== 1) throw new Error("Некорректный формат");
          let pa = (data.users || []).map(ud => User.fromObject(ud).save());
          if (pa) {
            Promise.all(pa).then(() => resolve()).catch(err => reject(err));
          }
        } catch(err) {
          reject(err);
        }
      });
      reader.addEventListener("error", err => reject(err));
    });
  }
}

/**
 * Базовая страница сайта с боковыми виджетами
 */
class GeneralPage extends Page {
  constructor() {
    super();
    this.name = "general";
    this._aside = [];
  }

  update() {
    super.update();
    this._aside = [];
    document.querySelectorAll("aside-widget").forEach(el => {
      let w = new AsideBookShelfWidget(el, {
        users: this.users,
        layout: { list: ".work-widget-list .book-row", default: "list" }
      });
      this._aside.push(w);
      w.update();
    });
    document.querySelectorAll(".aside-profile.profile-card").forEach(el => {
      let w = new UserCardWidget(el, {
        users: this.users
      });
      this._aside.push(w);
      w.update();
    });
  }

  _userUpdated(nick) {
    this._aside.forEach(w => w.userUpdated(nick));
  }
}

/**
 * Класс для обновления страниц профиля пользователя/автора
 */
class ProfilePage extends GeneralPage {
  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>div");
      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()));
    };
    super._userUpdated(nick);
  }

  /**
   * Привязывает пункт меню к действию по созданию и отображению диалогового окна
   *
   * @param menu_item Element HTML-элемент пункта меню для привязки
   *
   * @return void
   */
  _bindMenuItem(menu_item) {
    menu_item.addEventListener("click", event => {
      (async () => {
        try {
          let defaults = {};
          if (this.user.empty) {
            defaults.b_action = await DB.getSetting("authors.dialog.b_action");
            defaults.notes_profile = await DB.getSetting("authors.dialog.notes_profile");
          }
          defaults.hint = await DB.getSetting("authors.dialog.hint", true);
          let dlg = new UserModalDialog(this.user, {
            mobile: false,
            title: "AuthorTodayBlockList - Автор",
            defaults: defaults
          });
          dlg.show();
          dlg.element.addEventListener("submitted", () => {
            this._widgets.forEach(w => w.update());
            this._channel.postMessage(this.user.nick);
            dlg.hide();
          });
        } catch(err) {
          Notification.display(err.message, "error");
        }
      })();
    });
  }
}

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

  update() {
    [
      "mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
      "recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
    ].forEach(id => {
      let el = document.getElementById(id);
      if (el) {
        this._widgets.push(new SpinnerBookShelfWidget(el, {
          users: this.users,
          watch: false,
          layout: { grid: ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned)", default: "grid" }
        }));
      }
    });
    super.update();
  }
}

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

  update() {
    this._widgets = [];
    let el = document.getElementById("search-results");
    if (el) {
      this._widgets.push(new BookShelfWidget(el, {
        users: this.users,
        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 layout = {
      grid: ".book-shelf .bookcard",
      default: "grid"
    };
    switch ((new URL(document.location)).searchParams.get("category")) {
      case null:
      case "all":
        {
          let el = document.getElementById("search-results");
          if (el) {
            let w = new AjaxContainer(el, [
              [ new UsersWidget(null, { users: this.users }), ".flex-list" ],
              [ new BookShelfWidget(null, { users: this.users, layout: layout }), ".book-shelf" ]
            ]);
            this._widgets.push(w);
          }
        }
        break;
      case "authors":
        {
          let u_el = document.getElementById("search-results");
          u_el && this._widgets.push(new UsersWidget(u_el, { users: this.users, watch: true }));
        }
        break;
      case "works":
        {
          let b_el = document.getElementById("search-results");
          if (b_el) {
            b_el.style.overflowX = "hidden";
            layout.list = ".panel-body .book-row";
            layout.table = ".books-table tbody tr";
            layout.selector = ".panel-actions a.active i";
            this._widgets.push(new BookShelfWidget(b_el, { users: this.users, watch: true, layout: layout }));
          }
        }
        break;
    }
    super.update();
  }
}

/**
 * Класс для обновления страниц в библиотеках пользователей
 */
class LibraryPage extends GeneralPage {
  constructor() {
    super();
    this.name = "library";
  }

  update() {
    this._widgets = [];
    const layout = {
      grid: ".book-shelf .bookcard",
      default: "grid"
    };
    const c_el = document.getElementById("search-results");
    if (c_el) {
      this._widgets.push(new BookShelfWidget(c_el, { users: this.users, watch: false, layout: layout }));
    }
    super.update();
  }
}

/**
 * Класс для обновления страниц коллекций
 */
class CollectionPage extends GeneralPage {
  constructor() {
    super();
    this.name = "collection";
  }

  update() {
    this._widgets = [];
    let el = document.querySelector(".collection-page .collection-work-list");
    if (el) {
      this._widgets.push(new BookShelfWidget(el, { layout: { users: this.users, list: ".book-row", default: "list" } }));
    }
    super.update();
  }
}

/**
 * Класс для обновления страницы списка пользователей/авторов
 */
class UsersPage extends GeneralPage {
  constructor() {
    super();
    this.name = "users";
  }

  update() {
    let el = document.querySelector(".panel .panel-body .flex-list");
    if (!el) return;

    this._widgets = [ new UsersWidget(el, { users: this.users }) ];
    super.update();
  }
}

/**
 * Класс для управления страницей личного кабинета
 */
class AccountPage extends Page {
  constructor() {
    super();
    this.name = "account";
  }

  update() {
    this._widgets = [];
    try {
      const scr_page = (new URL(document.location)).searchParams.get("script") === "atbl";
      // Обновить меню, добавив пункт для скрипта
      let menu = document.querySelector("aside nav ul.nav:not(.atbl-handled)");
      if (menu) {
        this._createMenuItems(menu);
        menu.classList.add("atbl-handled");
        if (scr_page) {
          let active = menu.querySelector("li.active");
          if (active) {
            active.classList.remove("active");
            menu.querySelector("li.atbl-settings").classList.add("active");
          }
        }
      }

      if (scr_page && document.location.pathname === "/account/settings") {
        let sec = document.querySelector("#pjax-container section.content");
        if (!sec) return;
        // Активна страница настроек скрипта. Очистить ее.
        while (sec.firstChild) sec.lastChild.remove();
        // Добавить собственный виджет
        this._widgets.push(new SettingsWidget(sec, this._channel));
        // Обновить
        super.update();
      }
    } catch(err) {
      Notification.display(err.message, "error");
    }
  }

  /**
   * Создает элементы меню для отображения в основном меню личного кабинета пользователя
   *
   * @param menu Element HTML-элемент меню страницы настроек
   *
   * @return void
   */
  _createMenuItems(menu) {
    let item = document.createElement("li");
    if (!menu.querySelector("li.Ox90-settings-menu")) {
      item.classList.add("nav-heading", "Ox90-settings-menu");
      menu.appendChild(item);
      let span = document.createElement("span");
      item.appendChild(span);
      let img = document.createElement("i");
      img.classList.add("icon-cogs", "icon-fw");
      span.appendChild(img);
      span.appendChild(document.createTextNode(" Внешние скрипты"));
      item = document.createElement("li");
    }
    item.classList.add("atbl-settings");
    menu.appendChild(item);
    let ae = document.createElement("a");
    ae.classList.add("nav-link");
    ae.setAttribute("href", "/account/settings?script=atbl");
    ae.textContent = "AutorTodayBlackList";
    item.appendChild(ae);
  }
}

/**
 * Класс для манипуляции элементами карточки пользователя
 */
class UserElement {

  /**
   * Конструктор
   *
   * @param element Element HTML-элемент пользователя
   *
   * @return void
   */
  constructor(element) {
    this.element = element;
    this.nick = this._getNick();
    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");
  }

  /**
   * Извлекает ник пользователя
   *
   * @return string
   */
  _getNick() {
    return UserElement.userNick(this.element, ".card-content .user-info")[0];
  }
}

/**
 * Класс для манипуляции с боковым элементом пользователя (на страницах книг и блогов)
 */
class UserAsideElement extends UserElement {
  _getNick() {
    return UserElement.userNick(this.element)[0];
  }
}

/**
 * Базовый класс для манипуляции элементами книги разных видов
 */
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");
  }
}

/**
 * Класс для элемента книги в виде карточки с обложкой и краткой информацией внизу
 */
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 UserModalDialog extends ModalDialog {

  /**
   * Конструктор класса
   *
   * @param user   User   Автор
   * @param params Object Данные с полями mobile и title
   *
   * @return void
   */
  constructor(user, params) {
    super(params);
    this._user = user;
    this._params.defaults = this._params.defaults || {};
  }

  show() {
    this._params.body = this._createDialogContent();
    super.show();

    this.element.addEventListener("submit", event => {
      event.preventDefault();
      switch (event.submitter.name) {
        case "save":
          this._user.b_action = this.element.querySelector("select[name=b_action]").value;
          this._user.notes = {
            text: this.element.querySelector("textarea[name=notes_text]").value.trim(),
            profile: this.element.querySelector("input[name=notes_profile]").checked
          };
          this._user.save().then(() => {
            Notification.display("Данные успешно обновлены", "success");
            this.element.dispatchEvent(new Event("submitted"));
          }).catch(err => {
            Notification.display("Ошибка обновления данных", "error");
            console.warn("Ошибка обновления данных: " + err.message);
          });
          break;
        case "delete":
          if (confirm("Удалить автора из базы ATBL?")) {
            this._user.delete().then(() => {
              Notification.display("Запись успешно удалена", "success");
              this.element.dispatchEvent(new Event("submitted"));
            }).catch((err) => {
              Notification.display("Ошибка удаления записи", "error");
              console.warn("Ошибка удаления записи: " + err.message);
            });
          }
          break;
      }
    });
  }

  /**
   * Создает HTML-элемент form с полями ввода и кнопками для редактирования параметров автора
   *
   * @return Element
   */
  _createDialogContent() {
    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.style.fontSize = "110%";
    tdiv.style.borderBottom = "1px solid #e5e5e5";
    tdiv.style.paddingBottom = "5px";
    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(
      HTML.createSelectbox("b_action", [
        { value: "none", text: "Не трогать" },
        { value: "mark", text: "Помечать" }
      ], (this._user.empty ? this._params.defaults.b_action : this._user.b_action) || "none")
    );
    let nsec = document.createElement("div");
    idiv.appendChild(nsec);
    let nsp = document.createElement("label");
    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(HTML.createCheckbox(
      "Отображать короткую заметку в профиле (только 1-я строчка)",
      "notes_profile",
      (this._user.empty ? this._params.defaults.notes_profile : this._user.notes.profile) || false
    ));
    let usec = document.createElement("div");
    idiv.appendChild(usec);
    let uttl = document.createElement("label");
    uttl.textContent = "Последнее обновление";
    usec.appendChild(uttl);
    let uval = document.createElement("input");
    uval.readonly = true;
    uval.disabled = true;
    uval.classList.add("form-control");
    uval.value = this._user.lastUpdate && this._user.lastUpdate.toLocaleString() || "нет";
    usec.appendChild(uval);
    if (this._params.defaults.hint) {
      let hnt = document.createElement("div");
      hnt.textContent = "Вы всегда можете отменить это действие в личном кабинете, в настройках скрипта.";
      idiv.appendChild(hnt);
      hnt = document.createElement("div");
      hnt.textContent = "Обратите внимание: все настройки хранятся только в вашем браузере." +
        " Если вы захотите пренести настройки в другой браузер, воспользуйтесь экспортом настроек в личном кабинете.";
      idiv.appendChild(hnt);
    }
    let bdiv = document.createElement("div");
    bdiv.classList.add("atbl-button-container");
    form.appendChild(bdiv);
    bdiv.appendChild(HTML.createSubmitButton("save", this._user.empty && "Добавить" || "Обновить", "success"));
    let btn2 = HTML.createSubmitButton("delete", "Удалить", "danger");
    btn2.disabled = this._user.empty;
    bdiv.appendChild(btn2);
    let btn3 = document.createElement("button");
    btn3.classList.add("btn", "btn-default", "atbl-btn-close");
    btn3.textContent = "Отмена";
    bdiv.appendChild(btn3);
    return form;
  }
}

/**
 * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
 */
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 {...}"
 * @param string id  Id элемента стилей (не обязательно)
 *
 * @return void
 */
function addStyle(css, id) {
  const style = document.getElementById("atbl_styles") || (function() {
    const style = document.createElement('style');
    style.type = 'text/css';
    style.id = id || "atbl_styles";
    document.head.appendChild(style);
    return style;
  })();
  const sheet = style.sheet;
  sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}

/**
 * Преобразует hex цвет в rgb представление ("#ff0000" -> "255,0,0")
 *
 * @param hex string HEX представление цвета для преобразования
 *
 * @return string
 */
function hexToRgb(hex) {
  const res = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
  if (res) {
    return "" + parseInt(res[1], 16) + "," + parseInt(res[2], 16) + "," + parseInt(res[3], 16);
  }
  return "0,0,0";
}

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

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

})();