AO3Boxicons

Reusable library that initialized the boxicons css and serves functions to turn stats and menus into icons

สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.greasyfork.org/scripts/497064/1489249/AO3Boxicons.js

// ==UserScript==
// @exclude      *
// @author       Yours Truly
// @version      1.2.1

// ==UserLibrary==
// @name         AO3Boxicons
// @description  Reusable library that initialized the boxicons css and serves functions to turn stats and menus into icons
// @license      MIT

// ==/UserScript==

// ==/UserLibrary==

/**
 *
 * @param {Object} settings
 * @param {String} settings.boxiconsVersion Used version of https://boxicons.com/
 * @param {Boolean} settings.iconifyStats   Flag that indicates if the AO3 work stat names should be turned into icons
 * @param {Object} settings.statsSettings   Individual settings for stat icons
 *                                          that typically consist of { iconClass: string, solid: boolean, tooltip: string }
 * @param {Object} settings.statsSettings.wordCountOptions
 * @param {Object} settings.statsSettings.chaptersOptions
 * @param {Object} settings.statsSettings.collectionsOptions
 * @param {Object} settings.statsSettings.commentsOptions
 * @param {Object} settings.statsSettings.kudosOptions
 * @param {Object} settings.statsSettings.bookmarksOptions
 * @param {Object} settings.statsSettings.hitsOptions
 * @param {Object} settings.statsSettings.workSubsOptions
 * @param {Object} settings.statsSettings.authorSubsOptions
 * @param {Object} settings.statsSettings.commentThreadsOptions
 * @param {Object} settings.statsSettings.challengesOptions
 * @param {Object} settings.statsSettings.fandomsOptions
 * @param {Object} settings.statsSettings.requestOptions
 * @param {Object} settings.statsSettings.workCountOptions
 * @param {Object} settings.statsSettings.seriesCompleteOptions
 * @param {Object} settings.statsSettings.kudos2HitsOptions
 * @param {Object} settings.statsSettings.timeToReadOptions
 * @param {Object} settings.statsSettings.dateWorkPublishedOptions
 * @param {Object} settings.statsSettings.dateWorkUpdateOptions
 * @param {Object} settings.statsSettings.dateWorkCompleteOptions
 * @param {Object} settings.iconifyUserNav  Flag that indicates if the AO3 user navigation should be turned into icons
 * @param {Object} settings.userNavSettings Individual settings for user nav icons
 *                                          that typically consist of { iconClass: string, solid: boolean, tooltip: string, addTooltip: boolean }
 * @param {Object} settings.accountOptions
 * @param {Object} settings.postNewOptions
 * @param {Object} settings.logoutOptions
 *
 */
function IconifyAO3(customSettings = {}) {
  /**
   * Merges the second object into the first
   * If a value is in `a` but not in `b`, the value stays like it is.
   * If a value is in `b` but not in `a`, it gets copied over.
   * If a value is in both `a` and `b`, the value of `b` takes preference.
   *
   * @param {Object} a original settings
   * @param {Object} b user settings overwrite
   * @param {*} c used for temp storage, don't worry about it
   */
  function mergeSettings(a, b, c) {
    for (c in b) b.hasOwnProperty(c) && ((typeof a[c])[0] == "o" ? m(a[c], b[c]) : (a[c] = b[c]));
  }

  // set global settings and overwrite with incoming settings
  const settings = {
    boxiconsVersion: "2.1.4",
  };
  mergeSettings(settings, customSettings);

  /**
   * Initialises boxicons.com css and adds a small css to add some space between icon and stats count.
   */
  function initBoxicons() {
    // load boxicon style
    const boxicons = document.createElement("link");
    boxicons.setAttribute("href", `https://unpkg.com/boxicons@${settings?.boxiconsVersion}/css/boxicons.min.css`);
    boxicons.setAttribute("rel", "stylesheet");
    document.head.appendChild(boxicons);

    // css that adds margin for icons
    const boxiconsCSS = document.createElement("style");
    boxiconsCSS.setAttribute("type", "text/css");
    boxiconsCSS.innerHTML = `
        i.bx {
          margin-right: .3em;
        }`;
    document.head.appendChild(boxiconsCSS);
  }

  /**
   * Creates a new element with the icon class added to the classList.
   *
   * @param {Object} options
   * @param {String} options.iconClass    Name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
   * @param {String} options.tooltip      Adds an optional tooltip to the element.
   *                                      Only if `addTooltip = true`.
   * @param {Boolean} options.addTooltip  Indicates if a tooltip should be added to the element.
   *                                      `tooltip` needs to be present in `options`.
   * @param {Boolean} options.solid       Indicates if the icon should be of the "solid" variant.
   *                                      Will be ignored if `iconClass` has "bx(s)" prefix.
   * @returns <i> Element with the neccessary classes for a boxicons icon.
   */
  function getNewIconElement(options = {}) {
    const i = document.createElement("i");
    i.classList.add("bx");
    if (options?.addTooltip && options?.tooltip) i.setAttribute("title", options.tooltip);

    if (/^bxs?-/i.test(options.iconClass)) {
      // check if the icon class has the bx(s) prefix and simply set it, ignoring any settings for solid
      i.classList.add(options.iconClass);
    } else {
      // else, add the fittings prefix
      i.classList.add(options?.solid ? "bxs-" + options.iconClass : "bx-" + options.iconClass);
    }
    return i;
  }

  /**
   * Prepends the given boxicons class to the given element.
   * Note: If the element is an <i> tag, nothing will happen, as we assume that the <i> is already an icon.
   *
   * @param {HTMLElement} element      Parent element that the icon class should be prepended to.
   * @param {Object} options
   * @param {String} options.iconClass Name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
   * @param {String} options.tooltip   Adds a tooltip to the element
   * @param {Boolean} options.solid    Indicates if the icon should be of the "solid" variant.
   *                                   Will be ignored if iconClass has "bx(s)" prefix.
   */
  function setIcon(element, options = {}) {
    if (element.tagName !== "I") element.prepend(getNewIconElement(options));
    if (options?.tooltip) element.setAttribute("title", options.tooltip);
  }

  /**
   * Iterates through all elements that apply to the given querySelector and adds an element with the given icon class to it.
   *
   * @param {String} querySelector     CSS selector for the elements to find and iconify.
   * @param {Object} options
   * @param {String} options.iconClass Name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
   * @param {String} options.tooltip   Adds a tooltip to the element
   * @param {Boolean} options.solid    Indicates if the icon should be of the "solid" variant.
   *                                   Will be ignored if iconClass has "bx(s)" prefix.
   */
  function findElementsAndSetIcon(querySelector, options = {}) {
    const els = document.querySelectorAll(querySelector);
    els.forEach((el) => (el.firstChild.nodeType === Node.ELEMENT_NODE ? setIcon(el.firstChild, options) : setIcon(el, options)));
  }

  /**
   * Adds an CSS that will hide the stats titles and prepends an icon to all stats.
   */
  function iconifyStats() {
    const WordsTotal = "dl.statistics dd.words";
    const WordsWork = "dl.stats dd.words";
    const WordsSeries = ".series.meta.group dl.stats>dd:nth-of-type(1)";
    const ChaptersWork = "dl.stats dd.chapters";
    const CollectionsWork = "dl.stats dd.collections";
    const CommentsWork = "dl.stats dd.comments";
    const KudosTotal = "dl.statistics dd.kudos";
    const KudosWork = "dl.stats dd.kudos";
    const BookmarksTotal = "dl.statistics dd.bookmarks";
    const BookmarksWork = "dl.stats dd.bookmarks";
    const BookmarksSeries = ".series.meta.group dl.stats>dd:nth-of-type(4)";
    const BookmarksCollection = "li.collection dl.stats dd a[href$=bookmarks]";
    const HitsTotal = "dl.statistics dd.hits";
    const HitsWork = "dl.stats dd.hits";
    const SubscribersTotal = "dl.statistics dd[class=subscriptions]";
    const SubscribersWork = "dl.stats dd.subscriptions";
    const ChallengesCollection = "li.collection dl.stats dd a[href$=collections]";
    const FandomsCollection = "li.collection dl.stats dd a[href$=fandoms]";
    const RequestsCollection = "li.collection dl.stats dd a[href$=requests]";
    const AuthorSubscribers = "dl.statistics dd.user.subscriptions";
    const CommentThreads = "dl.statistics dd.comment.thread";
    const WorksCollection = "li.collection dl.stats dd a[href$=works]";
    const WorksSeries = ".series.meta.group dl.stats>dd:nth-of-type(2)";
    const SeriesComplete = ".series.meta.group dl.stats>dd:nth-of-type(3)";
    const Kudos2HitsWork = "dl.stats dd.kudos-hits-ratio";
    const ReadingTimeWork = "dl.stats dd.reading-time";
    const DatePublishedWork = "dl.work dl.stats dd.published";
    const DateStatusTitle = "dl.work dl.stats dt.status";
    const DateStatusWork = "dl.work dl.stats dd.status";

    const localSettings = {
      wordCountOptions: { tooltip: "Word Count", iconClass: "pen", solid: true },
      chaptersOptions: { tooltip: "Chapters", iconClass: "food-menu" },
      collectionsOptions: { tooltip: "Collections", iconClass: "collection", solid: true },
      commentsOptions: { tooltip: "Comments", iconClass: "chat", solid: true },
      kudosOptions: { tooltip: "Kudos", iconClass: "heart", solid: true },
      bookmarksOptions: { tooltip: "Bookmarks", iconClass: "bookmarks", solid: true },
      hitsOptions: { tooltip: "Hits", iconClass: "show-alt" },
      workSubsOptions: { tooltip: "Subscriptions", iconClass: "bell", solid: true },
      authorSubsOptions: { tooltip: "User Subscriptions", iconClass: "bell-ring", solid: true },
      commentThreadsOptions: { tooltip: "Comment Threads", iconClass: "conversation", solid: true },
      challengesOptions: { tooltip: "Challenges/Subcollections", iconClass: "collection", solid: false },
      fandomsOptions: { tooltip: "Fandoms", iconClass: "crown", solid: true },
      requestsOptions: { tooltip: "Prompts", iconClass: "invader", solid: true },
      workCountOptions: { tooltip: "Work Count", iconClass: "library" },
      seriesCompleteOptions: { tooltip: "Series Complete", iconClass: "flag-checkered", solid: true },
      kudos2HitsOptions: { tooltip: "Kudos to Hits", iconClass: "hot", solid: true },
      timeToReadOptions: { tooltip: "Time to Read", iconClass: "hourglass", solid: true },
      dateWorkPublishedOptions: { tooltip: "Published", iconClass: "calendar-plus" },
      dateWorkUpdateOptions: { tooltip: "Updated", iconClass: "calendar-edit" },
      dateWorkCompleteOptions: { tooltip: "Completed", iconClass: "calendar-check" },
    };
    // merge incoming settings into local settings (overwrite)
    mergeSettings(localSettings, settings?.statsSettings);

    // css to hide stats titles
    const statsCSS = document.createElement("style");
    statsCSS.setAttribute("type", "text/css");
    statsCSS.innerHTML = `
        dl.stats dt {
          display: none !important;
        }`;
    document.head.appendChild(statsCSS);

    findElementsAndSetIcon(`${WordsTotal}, ${WordsWork}, ${WordsSeries}`, localSettings.wordCountOptions);
    findElementsAndSetIcon(ChaptersWork, localSettings.chaptersOptions);
    findElementsAndSetIcon(CollectionsWork, localSettings.collectionsOptions);
    findElementsAndSetIcon(CommentsWork, localSettings.commentsOptions);
    findElementsAndSetIcon(`${KudosTotal}, ${KudosWork}`, localSettings.kudosOptions);
    findElementsAndSetIcon(`${BookmarksTotal}, ${BookmarksWork}, ${BookmarksCollection}, ${BookmarksSeries}`, localSettings.bookmarksOptions);
    findElementsAndSetIcon(`${HitsTotal}, ${HitsWork}`, localSettings.hitsOptions);
    findElementsAndSetIcon(`${SubscribersTotal}, ${SubscribersWork}`, localSettings.workSubsOptions);
    findElementsAndSetIcon(AuthorSubscribers, localSettings.authorSubsOptions);
    findElementsAndSetIcon(CommentThreads, localSettings.commentThreadsOptions);
    findElementsAndSetIcon(ChallengesCollection, localSettings.challengesOptions);
    findElementsAndSetIcon(FandomsCollection, localSettings.fandomsOptions);
    findElementsAndSetIcon(RequestsCollection, localSettings.requestsOptions);
    findElementsAndSetIcon(`${WorksCollection}, ${WorksSeries}`, localSettings.workCountOptions);
    findElementsAndSetIcon(SeriesComplete, localSettings.seriesCompleteOptions);

    // AO3E elements
    findElementsAndSetIcon(Kudos2HitsWork, localSettings.kudos2HitsOptions);
    findElementsAndSetIcon(ReadingTimeWork, localSettings.timeToReadOptions);

    // calendar icons at works page
    findElementsAndSetIcon(DatePublishedWork, localSettings.dateWorkPublishedOptions);
    const workStatus = document.querySelector(DateStatusTitle);
    if (workStatus && workStatus.innerHTML.startsWith("Updated")) {
      setIcon(document.querySelector(DateStatusWork), localSettings.dateWorkUpdateOptions);
    } else if (workStatus && workStatus.innerHTML.startsWith("Completed")) {
      setIcon(document.querySelector(DateStatusWork), localSettings.dateWorkCompleteOptions);
    }
  }

  /**
   * Replaces the "Hi, {user}!", "Post" and "Log out" text at the top of the page with icons.
   */
  function iconifyUserNav() {
    const localSettings = {
      accountOptions: { tooltip: "User Area", addTooltip: true, iconClass: "user-circle", solid: true },
      postNewOptions: { tooltip: "New Work", addTooltip: true, iconClass: "book-add", solid: true },
      logoutOptions: { tooltip: "Logout", addTooltip: true, iconClass: "log-out" },
    };
    // merge incoming settings into local settings (overwrite)
    mergeSettings(localSettings, settings?.userNavSettings);

    const AccountUserNav = "#header a.dropdown-toggle[href*='/users/']";
    const PostUserNav = "#header a.dropdown-toggle[href*='/works/new']";
    const LogoutUserNav = "#header a[href*='/users/logout']";

    // add css for user navigation icons
    const userNavCss = document.createElement("style");
    userNavCss.setAttribute("type", "text/css");
    userNavCss.innerHTML = `
      ${LogoutUserNav},
      ${AccountUserNav},
      ${PostUserNav} {
        /* font size needs to be higher to make icons the right size */
        font-size: 1.25rem;
        /* left and right padding for a slightly bigger hover hitbox */
        padding: 0 .3rem;
      }
     
      ${LogoutUserNav} i.bx {
        /* overwrite the right margin for logout icon */
        margin-right: 0;
        /* add left margin instead to add more space to user actions */
        margin-left: .3em;
      }`;
    document.head.appendChild(userNavCss);

    // replace text with icons
    document.querySelector(AccountUserNav).replaceChildren(getNewIconElement(localSettings.accountOptions));
    document.querySelector(PostUserNav).replaceChildren(getNewIconElement(localSettings.postNewOptions));
    document.querySelector(LogoutUserNav).replaceChildren(getNewIconElement(localSettings.logoutOptions));
  }

  initBoxicons();

  if (settings?.iconifyStats) iconifyStats();
  if (settings?.iconifyUserNav) iconifyUserNav();
}