AuthorTodayBlackList

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

目前為 2023-03-21 提交的版本,檢視 最新版本

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

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

(function start() {
  "use strict";

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

  let page = null;

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

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

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

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

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

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

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

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

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

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

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

        req.onsuccess = function() {
          user.lastUpdate = ct;
          resolve();
        };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * Класс для работы со списком пользователей в режиме кэша.
 * Предназначен для того, чтобы избежать дублирование запросов к базе данных.
 * Расширяет стандартный класс Map.
 */
class UserCache extends Map {

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

/**
 * Базовый класс для работы со страницами сайта
 */
class Page {
  constructor() {
    this.name = null;
    this._root = null; // Корневой элемент страницы
    this._channel = new BroadcastChannel("user-updated");
    this._channel.onmessage = event => this._userUpdated(event.data);
    this._layoutselector = null;
  }

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

  /**
   * Возвращает строку с идентификатором действия для указанной книги
   *
   * @param book BookElement Экземпляр класса книги
   *
   * @return string Возможные значения: 'mark', 'unmark', 'none'
   */
  getBookAction(book) {
    if (this._users && book.authors.length) {
      if (book.authors.every(nick => this._users.get(nick).b_action === "mark")) {
        return "mark";
      }
      return "unmark";
    }
    return "none";
  }

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

/**
 * Базовый класс для обновления страниц с книгами
 */
class BookShelfPage extends Page {

  /**
   * Конструктор класса
   *
   * @return void
   */
  constructor() {
    super();
    this._users = new UserCache();
    this._layout = { name: null, selector: null, list: null, grid: null, table: null };
  }

  /**
   * Метод асинхронного обновления блока с книжной полкой
   * Этот тип страницы может быть обновлен тремя способами:
   * - Классически, через обновление вкладки
   * - Обновлением всего блока сайта скриптом. Например при выборе следующей страницы категории
   * - Обновлением только блока с результатами запроса. Например при выборе жанра в верхней панели.
   * Поэтому необходимо повесить дополнительный наблюдатель на панель с результами запроса
   *
   * @return void
   */
  update() {
    if (!this._root) return;
    // Настроить скрытие плашки по клику
    this._root.addEventListener("click", function(event) {
      let fence = event.target.closest(".atbl-fence-block");
      if (fence) fence.classList.toggle("atbl-open");
    });
    // Установить наблюдатель на панель результатов
    (new MutationObserver(function() {
      if (!this._root.querySelector(".overlay")) this._updatePanel();
    }.bind(this))).observe(this._root, { childList: true });
    // Сканировать и обновить панель с книгами
    this._updatePanel();
    super.update();
  }

  /**
   * Извлекает из панели список авторов, проверяет их настройки и обновляет блок с книгами
   *
   * @return void
   */
  _updatePanel() {
    this._layout.name = this._getLayout();
    if (!this._layout.name) return;

    const query = this._layout[this._layout.name];
    if (!query) return;

    const authors = BookElement.getAuthorList(this._root);
    if (!authors.length) return;

    this._users.ensure(authors).then(() => {
      try {
        // Получить элементы книг и обработать их
        let books = this._root.querySelectorAll(query + ":not(.atbl-handled)");
        if (books.length) {
          books.forEach(be => {
            let book = this._getBook(be);
            switch (this.getBookAction(book)) {
              case "mark":
                book.mark();
                break;
            }
            book.element.classList.add("atbl-handled");
          });
        }
      } catch(err) {
        Notification.display(err.message, "error");
      }
    });
  }

  /**
   * Этот метод вызывается в случае изменения данных какого-нибудь автора в другой вкладке
   *
   * @param nick string Ник обновленного пользователя
   *
   * @return void
   */
  _userUpdated(nick) {
    let user = this._users.get(nick);
    if (!user) return;

    if (!this._layout.name) return;

    const query = this._layout[this._layout.name];
    if (!query) return;

    user.fetch().then(() => {
      this._root.querySelectorAll(query + ".atbl-handled").forEach(be => {
        let book = this._getBook(be);
        if (!book.hasAuthor(nick)) return;
        switch(this.getBookAction(book)) {
          case "mark":
            book.mark();
            break;
          case "unmark":
            book.unmark();
            break;
        }
      });
    });
  }

  /**
   * Возвращает наименование раскладки книжной полки
   *
   * @return string Одно из следующих значений: 'list', 'grid', 'table' или undefined
   */
  _getLayout() {
    if (this._layout.selector) {
      const ico = (this._root || document).querySelector(this._layout.selector);
      if (ico) {
        switch (ico.getAttribute("class")) {
          case "icon-list":
            return "list";
          case "icon-grid":
            return "grid";
          case "icon-bars":
            return "table";
        }
      }
    }
    if (this._layout.default) return this._layout.default;
  }

  /**
   * Возвращает экземляр класса BookElement с учетом текущей раскладки книжной полки
   *
   * @param el Element HTML-элемент книги
   *
   * @return BookElement
   */
  _getBook(el) {
    switch (this._layout.name) {
      case "list":
        return new BookRowElement(el);
      case "grid":
        return new BookCardElement(el);
      case "table":
        return new BookTableElement(el);
    }
    return new BookElement(el);
  }
}

/**
 * Класс для обновления страниц профиля пользователя/автора
 */
class ProfilePage extends Page {

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

    // Найти элемент с фамилией
    let fio_el = document.querySelector("h1>a[href^=\"/u/\"]");
    if (fio_el) {
      let fio = fio_el.textContent.trim();
      if (fio !== "") {
        this.user = new User(nick, fio);
        this.name = "profile";
      }
    }
  }

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

    this.user.fetch().then(() => {
      this._updateProfileAvatar();
      this._updateProfileNotes();
      this._updateProfileMenu();
    });
  }

  /**
   * Какой-то пользователь был обновлен в другой вкладке.
   * Если совпадает с пользователем профиля, то обновить
   *
   * @param nick string Ник обновленного пользователя
   *
   * @return void
   */
  _userUpdated(nick) {
    if (this.user && this.user.nick === nick) this.update();
  }

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

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

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

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

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

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

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

/**
 * Класс для отслеживания и обновления заглавной страницы сайта
 */
class MainPage extends Page {
  constructor() {
    super();
    this.name = "main";
    this._users = new UserCache();
    this._panels = [
      "mostPopularWorks", "hotWorks", "recentUpdWorks", "bestsellerWorks",
      "recentlyViewed", "recentPubWorks", "addedToLibraryWorks", "recentLikedWorks"
    ];
  }

  /**
   * Метод для асинхронного обновления страницы сайта.
   * В случае, если книги подгружаются в панель отдельным запросом,
   * то на такую панель вешается наблюдатель и панель обновляется по готовности.
   *
   * @return void
   */
  update() {
    this._panels.forEach(function(panel_id) {
      let panel_el = document.getElementById(panel_id);
      if (panel_el) {
        // Обработчик для клика по метке
        panel_el.addEventListener("click", function(event) {
          let fence = event.target.closest(".atbl-fence-block");
          fence && fence.classList.toggle("atbl-open");
        });
        // Запустить сканирование панели
        this._scanPanel(panel_el);
      }
    }.bind(this));
  }

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

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

  /**
   * Получает список авторов панели и запускает обновление обложек книг, если необходимо
   *
   * @param panel_el Element HTML-элемент панели для обновления
   *
   * @return void
   */
  _updatePanel(panel_el) {
    try {
      // Получить список авторов панели и запустить их синхронизацию
      const authors = BookElement.getAuthorList(panel_el, ".bookcard-footer>.bookcard-authors");
      if (!authors.length) return;
      this._users.ensure(authors).then(() => {
        // Теперь необходимо обновить книги
        panel_el.querySelectorAll(
          ".slick-list>.slick-track>.bookcard.slick-slide:not(.slick-cloned):not(.atbl-handled)"
        ).forEach(be => {
          let book = new BookCardElement(be);
          switch (this.getBookAction(book)) {
            case "mark":
              book.mark();
              break;
          }
          book.element.classList.add("atbl-handled");
        });
      });
    } catch(err) {
      Notification.display(err.message, "error");
    }
  }

  /**
   * Инициация обновления страницы если данные автора были обновлены в другой вкладке
   *
   * @param nick string Ник обновленного автора
   *
   * @return void
   */
  _userUpdated(nick) {
    let user = this._users.get(nick);
    if (!user) return;

    user.fetch().then(() => {
      this._panels.forEach(p_id => {
        document.querySelectorAll("#" + p_id + " .bookcard.atbl-handled").forEach(be => {
          let book = new BookCardElement(be);
          if (book.hasAuthor(nick)) {
            switch (this.getBookAction(book)) {
              case "mark":
                book.mark();
                break;
              case "unmark":
                book.unmark();
                break;
            }
          }
        });
      });
    });
  }
}

/**
 * Класс для обновления страницы группировки книг по жанрам, популярности, etc
 */
class CategoriesPage extends BookShelfPage {
  constructor() {
    super();
    this.name = "categories";
    this._layout.selector = ".panel-actions.pull-right button.active i";
    this._layout.list = ".book-row";
    this._layout.grid = ".book-shelf .bookcard";
    this._layout.table = ".books-table tbody tr";
  }

  update() {
    this._root = document.getElementById("search-results");
    this._root.style.overflowX = "hidden";
    super.update();
  }
}

/**
 * Класс для обновления страницы результатов поиска по тексту
 */
class SearchPage extends BookShelfPage {
  constructor() {
    super();
    this.name = "search";
    this._layout.selector = ".panel-actions a.active i";
    this._layout.list = ".panel-body .book-row";
    this._layout.grid = ".book-shelf .bookcard";
    this._layout.table = ".books-table tbody tr";
    this._layout.default = "grid";
  }

  update() {
    switch ((new URL(document.location)).searchParams.get("category")) {
      case "works":
        this._root = document.getElementById("search-results");
        this._root && (this._root.style.overflowX = "hidden");
        break;
      case null:
      case "all":
        this._root = document.querySelector("#search-results .book-shelf");
        break;
      default:
        return;
    }
    super.update();
  }
}

/**
 * Класс для манипулящии элементами с данными пользователей
 */
class UserElement {

  /**
   * Конструктор
   *
   * @param element Element HTML-элемент пользователя
   *
   * @return void
   */
  constructor(element) {
    this.element = element;
    this.nick = UserElement.userNick(element, ".card-content .user-info")[0];
  }

  /**
   * Возвращает ники пользователей, найденные в переданном элементе без проверки на уникальность
   *
   * @param element  Element HTML-элемент для сканирования
   * @param selector string  Уточняющий CSS селекор (необязательный параметр)
   *
   * @return array
   */
  static userNick(element, selector) {
    let list = [];
    let sel = 'a[href^="/u/"]';
    if (selector) sel = selector + " " + sel;
    element.querySelectorAll(sel).forEach(function (ael) {
      let r = /^\/u\/([^\/]+)/.exec(ael.getAttribute("href"));
      if (r) list.push(r[1].trim());
    });
    return list;
  }
}

/**
 * Базовый класс для манипуляции элементами книги разных видов
 */
class BookElement {

  /**
   * Конструктор
   *
   * @param element Element HTML-элемент книги
   *
   * @return void
   */
  constructor(element) {
    this.element = element;
    this.authors = [];
    this._fence = null;
  }

  /**
   * Проверяет, входил ли автор в список авторов книги
   *
   * @param nick string Ник автора для проверки
   *
   * @return bool
   */
  hasAuthor(nick) {
    return this.authors.includes(nick);
  }

  /**
   * Маркирует книгу
   *
   * @return void
   */
  mark() {
    if (this.element.classList.contains("atbl-marked")) return;
    this._fence = document.createElement("div");
    this._fence.classList.add("atbl-fence-block", "noselect");
    let note = document.createElement("div");
    note.classList.add("atbl-note");
    note.textContent = "Автор в ЧС";
    this._fence.appendChild(note);
    this.element.appendChild(this._fence);
    this.element.classList.add("atbl-marked");
  }

  /**
   * Снимает пометку с книги
   *
   * @return void
   */
  unmark() {
    if (this._fence) {
      this._fence.remove();
      this._fence = null;
    }
    this.element.classList.remove("atbl-marked");
  }

  /**
   * Возвращает список авторов в переданном элементе, исключая повторения
   *
   * @param element  Element HTML-элемент для поиска ссылок с авторами
   * @param selector string  Уточняющий CSS селектор (не обязательный параметр)
   *
   * @return Array
   */
  static getAuthorList(element, selector) {
    return Array.from(new Set(UserElement.userNick(element, selector)));
  }
}

/**
 * Класс для элемента книги в виде прямоугольно блока с обложкой и подробной информацией о книге
 */
class BookRowElement extends BookElement {
  constructor(element) {
    super(element);
    this.authors = BookElement.getAuthorList(this.element, ".book-row-content .book-author");
  }

  mark() {
    super.mark();
    this._fence.style.top = "-10px";
  }
}

/**
 * Класс для элемента книги в виде карточки с обложкой и краткой информацией внизу
 */
class BookCardElement extends BookElement {
  constructor(element) {
    super(element);
    this.authors = BookElement.getAuthorList(this.element, ".bookcard-footer .bookcard-authors");
  }
}

/**
 * Класс для элемента книги в виде строки таблицы, без обложки
 */
class BookTableElement extends BookElement {
  constructor(element) {
    super(element);
    this.authors = BookElement.getAuthorList(this.element, "td:nth-child(2)");
  }
}

/**
 * Класс для отображения модального диалогового окна в стиле сайта
 */
class ModalDialog {

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

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

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

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

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

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

    this.element.focus();
  }

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

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

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

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

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

    this.element.focus();
  }

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

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

/**
 * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
 */
class Notification {

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

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

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

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

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

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

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址