Libib - Custom status indicator style

Set a custom color and style for libib.com item status indicator and more

// ==UserScript==
// @name               Libib - Custom status indicator style
// @name:it            Libib - Stile indicatore stato personalizzato
// @description        Set a custom color and style for libib.com item status indicator and more
// @description:it     Modifica i colori e lo stile dell'indicatore dello stato di un oggetto di libib.com
// @author             JetpackCat
// @namespace          https://github.com/JetpackCat-IT/libib-custom-status-style
// @supportURL         https://github.com/JetpackCat-IT/libib-custom-status-style/issues
// @icon               https://github.com/JetpackCat-IT/libib-custom-status-style/raw/v1.0.0/img/icon_64.png
// @version            2.0.0
// @license            GPL-3.0-or-later; https://raw.githubusercontent.com/JetpackCat-IT/libib-custom-status-style/master/LICENSE
// @match              https://www.libib.com/library
// @icon               https://www.libib.com/img/favicon.png
// @run-at             document-idle
// @require            https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM.getValue
// @grant              GM.setValue
// ==/UserScript==

(function () {
  "use strict";

  // Get libib sidebar menu. The settings button will be added to the sidebar
  const libib_sidebar_menu = document.getElementById("primary-menu");

  // Create the element, it needs to be an <a> tag inside an <li> tag
  const settings_button_a = document.createElement("a");
  settings_button_a.appendChild(
      document.createTextNode("Libib status settings")
  );

  // Create <li> element and insert <a> element inside
  const settings_button_li = document.createElement("li");
  settings_button_li.appendChild(settings_button_a);

  // Assign click event handler to open the menu settings
  settings_button_li.addEventListener("click", function () {
      gmc.open();
  });

  // Add <li> element to the sidebar
  libib_sidebar_menu.appendChild(settings_button_li);

  // Create a container for the configuration elements
  const config_container = document.createElement("div");
  document.body.appendChild(config_container);

  const copyToClipboard = (text) => {
      const textarea = document.createElement('textarea');
      textarea.value = text;
      textarea.style.position = 'fixed';
      document.body.appendChild(textarea);
      textarea.focus();
      textarea.select();
      try {
          document.execCommand('copy');
      } catch (err) {
          console.error('Failed to copy text: ', err);
      }
      document.body.removeChild(textarea);
  }

  const readFromClipboard = async () => {
      return await navigator.clipboard.readText();
  }

  // For cover blur
  let blur_groups = [];

  // Adapt container background color and shadow based on libib theme (dark/light)
  const is_dark_scheme = document.body.classList.contains("dark");
  let background_color = "#fefefe";
  let shadow_color = "#838383";

  if (is_dark_scheme) {
      background_color = "#1b1b1b";
      shadow_color = "#e7e7e7";
  }
  const config_panel_css = `#libib_status_config{padding: 20px !important; background-color: ${background_color}; box-shadow: 0px 0px 9px 3px ${shadow_color}}; `;

  let gmc = new GM_config({
      id: "libib_status_config", // The id used for this instance of GM_config
      title: "Script Settings", // Panel Title
      types: {
          // Create color input type
          color: {
              default: null,
              toNode: function () {
                  var field = this.settings,
                      value = this.value,
                      id = this.id,
                      create = this.create,
                      slash = null,
                      retNode = create("div", {
                          className: "config_var",
                          id: this.configId + "_" + id + "_var",
                          title: field.title || "",
                      });

                  // Create the field lable
                  retNode.appendChild(
                      create("label", {
                          innerHTML: field.label,
                          id: this.configId + "_" + id + "_field_label",
                          for: this.configId + "_field_" + id,
                          className: "field_label",
                      })
                  );
                  // Create the actual input element
                  var props = {
                      id: this.configId + "_field_" + id,
                      type: "color",
                      value: value ?? "",
                  };
                  // Actually create and append the input element
                  retNode.appendChild(create("input", props));
                  return retNode;
              },
              toValue: function () {
                  let input = document.getElementById(
                      `${this.configId}_field_${this.id}`
                  );
                  if(input != null) return input.value;
              },
              reset: function () {
                  let input = document.getElementById(
                      `${this.configId}_field_${this.id}`
                  );
                  input.value = this.default;
              },
          },
          number: {
              default: null,
              toNode: function () {
                  var field = this.settings,
                      value = this.value,
                      id = this.id,
                      create = this.create,
                      slash = null,
                      retNode = create("div", {
                          className: "config_var",
                          id: this.configId + "_" + id + "_var",
                          title: field.title || "",
                      });

                  // Create the field lable
                  retNode.appendChild(
                      create("label", {
                          innerHTML: field.label,
                          id: this.configId + "_" + id + "_field_label",
                          for: this.configId + "_field_" + id,
                          className: "field_label",
                      })
                  );
                  // Create the actual input element
                  var props = {
                      id: this.configId + "_field_" + id,
                      type: "number",
                      value: value ?? "",
                  };
                  // Actually create and append the input element
                  retNode.appendChild(create("input", props));
                  return retNode;
              },
              toValue: function () {
                  let input = document.getElementById(
                      `${this.configId}_field_${this.id}`
                  );
                  if(input != null) return input.value;
              },
              reset: function () {
                  let input = document.getElementById(
                      `${this.configId}_field_${this.id}`
                  );
                  input.value = this.default;
              },
          },
      },
      // Fields object
      fields: {
          // This is the id of the field
          type: {
              label: "Indicator type", // Appears next to field
              type: "radio", // Makes this setting a radio field
              options: ["Triangle", "Border"], // Default = triangle
              default: "Triangle", // Default value if user doesn't change it
          },
          // This is the id of the field
          trianglePosition: {
              label: "Triangle position", // Appears next to field
              type: "select", // Makes this setting a select field
              options: ["Top left", "Top right", "Bottom left", "Bottom right"],
              default: "Top left", // Default value if user doesn't change it
          },
          // This is the id of the field
          borderPosition: {
              label: "Border position", // Appears next to field
              type: "select", // Makes this setting a select field
              options: ["Top", "Bottom"],
              default: "Top", // Default value if user doesn't change it
          },
          // This is the id of the field
          borderHeight: {
              label: "Border height", // Appears next to field
              type: "number", // Makes this setting a number field
              default: 5, // Default value if user doesn't change it
          },
          // This is the id of the field
          colorNotBegun: {
              label: '"Not begun" Color', // Appears next to field
              type: "color", // Makes this setting a color field
              default: "#ffffff", // Default value if user doesn't change it
          },
          // This is the id of the field
          colorCompleted: {
              label: '"Completed" Color', // Appears next to field
              type: "color", // Makes this setting a color field
              default: "#76eb99", // Default value if user doesn't change it
          },
          // This is the id of the field
          colorProgress: {
              label: '"In progress" Color', // Appears next to field
              type: "color", // Makes this setting a color field
              default: "#ffec8a", // Default value if user doesn't change it
          },
          // This is the id of the field
          colorAbandoned: {
              label: '"Abandoned" Color', // Appears next to field
              type: "color", // Makes this setting a color field
              default: "#ff7a7a", // Default value if user doesn't change it
          },
          // This is the id of the field
          blurGroups: {
              section: ['Blur (18+ content)', 'Blur all covers from specified groups (separated by ";") (ex. Naruto;One Piece)'],
              type: "string", // Makes this setting a text field
              default: "", // Default value if user doesn't change it
          },
          // This is the id of the field
          noBlurOnHover: {
              label: 'Disable blur on hover', // Appears next to field
              type: "checkbox", // Makes this setting a checkbox field
              default: false, // Default value if user doesn't change it
          },
          copySettings:
          {
              section: ['Import/Export'],
              'label': 'Copy settings', // Appears on the button
              'type': 'button', // Makes this setting a button input
              'size': 100, // Control the size of the button (default is 25)
              'click': function() { // Function to call when button is clicked
                  const result = Object.values(gmc.fields).map(item => ({
                      id: item.id,
                      value: item.value
                  }));
                  copyToClipboard(JSON.stringify(result))
              }
          },
          pasteSettings:
          {
              'label': 'Paste settings', // Appears on the button
              'type': 'button', // Makes this setting a button input
              'size': 100, // Control the size of the button (default is 25)
              'click': async function() { // Function to call when button is clicked
                  const settings = await readFromClipboard();
                  let options = [];

                  try {
                    options = JSON.parse(settings);
                  } catch {
                      return;
                  }

                  // Loop each settings and save
                  options.forEach(el => {
                      gmc.set(el.id, el.value);
                  });
                  // Save settings
                  gmc.save();
              }
          }
      },
      css: config_panel_css,
      frame: config_container,
      // Callback functions object
      events: {
          init: function () {
              let css = generateCSS(this);
              setCustomStyle(css);
              loadBlurredCovers(this);
          },
          save: function () {
              let css = generateCSS(this);
              setCustomStyle(css);
              loadBlurredCovers(this);
              this.close();
          },
      },
  });

  // Apply blur to initial loaded covers
  const loadBlurredCovers = function(GM_settings) {
      if (GM_settings == null) GM_settings = gmc;

      // Remove class from loaded items before apply
      const blurred_items = document.getElementsByClassName("cover-blur");
      Array.from(blurred_items).forEach(el => el.classList.remove("cover-blur"));

      const blur_groups_string = GM_settings.get("blurGroups");
      blur_groups = blur_groups_string.split(";");

      // Foreach word in blur_group, search elements
      blur_groups.forEach(word => {
          const divs = document.getElementsByClassName('item-group');
          Array.from(divs).forEach(item => {
              if (item.firstChild.textContent.trim() === word) {
                  // Add class to first child of parent (this will probably break at some point)
                  const parent = item.parentNode;
                  if (parent && parent.firstChild) {
                      parent.firstChild.classList.add("cover-blur");
                  }
              }
          });
      });
  };

  const generateCSS = function (GM_settings) {
      if (GM_settings == null) GM_settings = gmc;

      const not_begun_color = GM_settings.get("colorNotBegun");
      const completed_color = GM_settings.get("colorCompleted");
      const in_progress_color = GM_settings.get("colorProgress");
      const abandoned_color = GM_settings.get("colorAbandoned");
      const no_blur_on_hover = GM_settings.get("noBlurOnHover");

      let css_style = "";
      // Make libib buttons still clickable
      css_style += `
      .quick-edit-link{
          z-index: 10;
      }
      .quick-blur-link{
          position: absolute;
          height: 24px;
          width: 24px;
          top: 5px;
          left: 5px;
          border: none;
          background-color: #fff;
          background-image: url(/img/library/icons/icon-flag-item.svg);
          opacity: 0;
          border-radius: 100px;
          transition: all 0.3s ease-in-out;
          cursor: pointer;
          text-indent: -99999px;
          z-index: 10;
      }
      .item.cover:hover .quick-blur-link {
          opacity: 1;
      }
      .batch-select{
          z-index: 10;
      }
      .cover-blur{
          overflow: hidden;
      }
      .cover-blur img{
          filter: blur(8px);
      }`;
      // Disable blur on cover hover
      if(no_blur_on_hover){
          css_style += `
        .cover-blur:hover img{
          filter: blur(0px);
        }`;
      }
      // Set the save, close and reset buttons color to white id dark mode
      css_style += `
      body.dark #libib_status_config_resetLink,body.dark #libib_status_config_saveBtn,body.dark #libib_status_config_closeBtn{
      color:white!important
      }`;

      // Triangle style
      if (GM_settings.get("type") == "Triangle") {
          let triangle_position = GM_settings.get("trianglePosition");
          if (triangle_position == "Top left") {
              css_style += `
          .cover .completed.cover-wrapper::after {
              border-left-color: ${completed_color};
              border-top-color: ${completed_color};
          }
          .cover .in-progress.cover-wrapper::after {
              border-left-color: ${in_progress_color};
              border-top-color: ${in_progress_color};
          }
          .cover .abandoned.cover-wrapper::after {
              border-left-color: ${abandoned_color};
              border-top-color: ${abandoned_color};
          }
          .cover .not-begun.cover-wrapper::after {
              border-left-color: ${not_begun_color};
              border-top-color: ${not_begun_color};
          }
          `;
          } else if (triangle_position == "Top right") {
              css_style += `
              .cover .cover-wrapper::after{
              right: 0;
              left: auto;
              }
          .cover .completed.cover-wrapper::after {
              border-left-color: transparent;
              border-right-color: ${completed_color};
              border-top-color: ${completed_color};
          }
          .cover .in-progress.cover-wrapper::after {
              border-left-color: transparent;
              border-right-color: ${in_progress_color};
              border-top-color: ${in_progress_color};
          }
          .cover .abandoned.cover-wrapper::after {
              border-left-color: transparent;
              border-right-color: ${abandoned_color};
              border-top-color: ${abandoned_color};
          }
          .cover .not-begun.cover-wrapper::after {
              border-left-color: transparent;
              border-right-color: ${not_begun_color};
              border-top-color: ${not_begun_color};
          }
          `;
          } else if (triangle_position == "Bottom left") {
              css_style += `
              .cover .cover-wrapper::after{
              bottom: 0;
              top: auto;
              }
          .cover .completed.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: ${completed_color};
              border-bottom-color: ${completed_color};
          }
          .cover .in-progress.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: ${in_progress_color};
              border-bottom-color: ${in_progress_color};
          }
          .cover .abandoned.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: ${abandoned_color};
              border-bottom-color: ${abandoned_color};
          }
          .cover .not-begun.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: ${not_begun_color};
              border-bottom-color: ${not_begun_color};
          }
          `;
          } else if (triangle_position == "Bottom right") {
              css_style += `
              .cover .cover-wrapper::after{
              bottom: 0;
              top: auto;
              left: auto;
              right: 0;
              }
          .cover .completed.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: transparent;
              border-right-color: ${completed_color};
              border-bottom-color: ${completed_color};
          }
          .cover .in-progress.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: transparent;
              border-right-color: ${in_progress_color};
              border-bottom-color: ${in_progress_color};
          }
          .cover .abandoned.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: transparent;
              border-right-color: ${abandoned_color};
              border-bottom-color: ${abandoned_color};
          }
          .cover .not-begun.cover-wrapper::after {
              border-top-color: transparent;
              border-left-color: transparent;
              border-right-color: ${not_begun_color};
              border-bottom-color: ${not_begun_color};
          }
          `;
          }
      } else if (GM_settings.get("type") == "Border") {
          let border_position = GM_settings.get("borderPosition");
          let border_height = GM_settings.get("borderHeight");
          // The box-shadow prevents the click on the item, so it needs to be hidden on hover
          css_style += `
          .cover-wrapper {
              --shadow-y: ${
          border_position == "Top" ? `` : `-`
      }${border_height}px;
          }
          .cover-wrapper:hover::after {
              display:none!important;
              --shadow-y: 0px;
              transition: all 0.25s;
              transition-behavior: allow-discrete;
           }`;

          css_style += `
          .cover .cover-wrapper::before, .cover .cover-wrapper::after {
              width: 100%;
              height: 100%;
              border-radius: 4px;
              display: block;
              border: none;
              z-index: 0;
          }
          .cover .completed.cover-wrapper::after {
              box-shadow: inset 0px var(--shadow-y) ${completed_color};
          }
          .cover .in-progress.cover-wrapper::after {
              box-shadow: inset 0px var(--shadow-y) ${in_progress_color};
          }
          .cover .abandoned.cover-wrapper::after {
              box-shadow: inset 0px var(--shadow-y) ${abandoned_color};
          }
          .cover .not-begun.cover-wrapper::after {
              box-shadow: inset 0px var(--shadow-y) ${not_begun_color};
          }
          `;
      }
      return css_style;
  };

  const setCustomStyle = function (css) {
      // Remove existing style if present
      const existingStyle = document.getElementById(
          "libib-custom-status-indicator-style"
      );
      if (existingStyle != null) {
          existingStyle.remove();
      }

      // Add style tag to document
      document.head.append(
          Object.assign(document.createElement("style"), {
              type: "text/css",
              id: "libib-custom-status-indicator-style",
              textContent: css,
          })
      );
  };

  // Add the item group to the 'blurGroups' if not present, if already present remove it
  const toggleBlurForGroup = (div) => {
      // Search for the span containing the item group
      const span = div.target.parentNode.parentNode.querySelectorAll(".item-group>span");
      if(span.length != 1) return;

      // Create array from blurredGroups string
      let blurred_groups_string = gmc.get("blurGroups");
      if(blurred_groups_string == null) return;
      let blurred_groups = blurred_groups_string.split(";");
      let item_group = span[0].innerText;
      const index = blurred_groups.indexOf(item_group);
      // If item found, remove it
      if(index > -1) blurred_groups.splice(index, 1);
      // If not found, add to array
      else blurred_groups.push(item_group);

      // Save to settings
      gmc.set("blurGroups", blurred_groups.join(";"));
      gmc.save();
  }
  // Create the button for flagging groups to blur
  const createSetBlurButton = () => {
      const newDiv = document.createElement("div");
      newDiv.classList.add("quick-blur-link");
      newDiv.title = "Toggle blur for group";
      newDiv.addEventListener("click",toggleBlurForGroup);
      return newDiv;
  }
  // Check if the group of this item needs to be blurred
  const divNeedsBlur = (div) => {
      const spans = div.querySelectorAll('span');
      return Array.from(spans).some(span => span.parentNode.classList.contains("item-group") && blur_groups.includes(span.textContent.trim()) );
  }
  const divIsCover = (div) => {
      if(div.classList.contains("cover")) return true;
      return false;
  }

  // Run when new books get loaded on the page
  // Check new nodes
  function findDivInNode(node) {
      if (node.nodeType === Node.ELEMENT_NODE) {
          if (node.tagName.toLowerCase() === 'div' && divIsCover(node)) {
              node.firstChild.appendChild(createSetBlurButton());
              if(divNeedsBlur(node)){
                  node.firstChild.classList.add("cover-blur");
              }
          }
      }
  }

  // Setup observer
  const blur_observer = new MutationObserver(mutations => {
      for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
              findDivInNode(node);
          }
      }
  });

  // Start observer
  blur_observer.observe(document.body, { childList: true, subtree: true });

})();