Inventory Sorter

Allows you to sort your inventory in ascending/descending order.

// ==UserScript==
// @name          Inventory Sorter
// @namespace     https://greasyfork.org/en/users/1362698-iambatman
// @description   Allows you to sort your inventory in ascending/descending order.
// @version       2.0
// @author        IAmBatman [2885239]
// @grant         GM_addStyle
// @grant         GM_setValue
// @grant         GM_getValue
// @grant         GM_deleteValue
// @match         https://www.torn.com/item.php
// ==/UserScript==

(async function () {
  "use strict";
  GM_addStyle(`
.is-container {
  height: 100%;
  margin-right: 2px;

  position: relative;

  display: flex;
  align-items: center;
}

.is-btn {
  height: 22px !important;
  padding: 0 5px !important;
  line-height: 0 !important;
}

.is-modal {
  height: 150px;
  width: 300px;
  margin-top: 8px;
/*   background-color: var(--default-bg-panel-color); */
  background-color: #333;
  border-radius: 5px;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  box-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;

  position: absolute;


  display: grid;
  justify-items: center;
  grid-auto-rows: min-content;
}

.is-modal-header{
  font-size: 18px;
  font-weight: bold;
  line-height: 1;
  padding: 8px 0 12px 0;
  text-decoration: underline;
}

.is-modal-desc{
  font-size: 12px;
  text-align: center;
  margin: 0 12px 0 12px;
  line-height: 1;
}

.is-form{
  text-align: center;
  margin-top: 16px;

  position: relative;

  display: grid;
  align-items: center;
  justify-items: center;
  gap: 8px;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: repeat(2, 1fr);

}

.is-form-text{
  text-align: center;
  width: 160px;
  height: 28px;
  line-height: 0;
  border-radius: 5px;
  background-color: #000;
  color: #9f9f9f;
  border-color: transparent transparent #333 transparent;
  grid-column: span 2 / span 2;
}

.is-form-submit{
  border-radius: 5px !important;
  width: 48px !important;
  height: 24px !important;
  line-height: 0 !important;
  grid-column-start: 1;
  grid-row-start: 2;
}

.is-form-status{
  grid-column-start: 2;
  grid-row-start: 2;
  margin-left: -8px;
}

.is-form-delete-btn{
  cursor: pointer;
  top: 0;
  right: 0;
  transform: translateX(125%);

  position: absolute;
}

.is-form-delete-icon{
  height: 24px;
  width: 24px;
  stroke: #fff;
}
  .is-item-value{
  width: auto !important;
  padding: 0 10px 0 10px !important;
}

.is-item-value-color{
  color: var(--default-green-color);
}

.is-item-qty{
  font-weight: bold;
}

li:has(.group-arrow) .is-item-value{
  width: auto !important;
  padding: 0 30px 0 10px !important;
}
  `);

  let tabs = {};
  let itemValues = {};
  let sortState = "default";
  let posBeforeScroll = null;
  let itemValueSource;

  if (await isKeySaved()) {
    itemValueSource = "is";
    fetchApiValues();
  } else {
    itemValueSource = "tt";
  }

  let currentTab = getCurrentTabElement();
  let currentTabIndex = getCurrentTabIndex();
  recordTab();

  const itemObserver = new MutationObserver((mutationList, observer) => {
    mutationList.forEach((mutation) => {
      if (mutation.type === "childList") {
        if (mutation.addedNodes.length) {
          recordTab();
        }
      }
    });
  });

  const container = document.createElement("span");
  container.classList.add("is-container");
  container.classList.add("right");

  const btn = document.createElement("button");
  btn.classList.add("is-btn");
  btn.classList.add("torn-btn");
  btn.classList.add("dark-mode");
  btn.textContent = "IS";

  const modal = document.createElement("div");
  modal.classList.add("is-modal");
  modal.classList.add("hide");

  const modalHeader = document.createElement("p");
  modalHeader.classList.add("is-modal-header");
  modalHeader.textContent = "INVENTORY SORTER";

  const modalDescription = document.createElement("p");
  modalDescription.classList.add("is-modal-desc");
  modalDescription.textContent = `A public API key is sufficient. The key is stored on your browser, and it's not sent anywhere.`;

  modal.appendChild(modalHeader);
  modal.appendChild(modalDescription);

  const form = document.createElement("form");
  form.classList.add("is-form");

  const formTextInput = document.createElement("input");
  formTextInput.classList.add("is-form-text");

  const formSubmit = document.createElement("input");
  formSubmit.classList.add("is-form-submit");
  formSubmit.classList.add("torn-btn");
  formSubmit.classList.add("dark-mode");

  const formKeyStatusText = document.createElement("p");
  formKeyStatusText.classList.add("is-form-status");

  const formDeleteBtn = document.createElement("button");
  formDeleteBtn.classList.add("is-form-delete-btn");

  if (await isKeySaved()) {
    formKeyStatusText.textContent = "Key: Saved";
    formDeleteBtn.classList.remove("hide");
  } else {
    formKeyStatusText.textContent = "Key: Not Saved";
    formDeleteBtn.classList.add("hide");
  }

  const deleteBtnSvg = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="is-form-delete-icon">
                    <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
                </svg>`;

  formDeleteBtn.insertAdjacentHTML("afterbegin", deleteBtnSvg);

  formTextInput.setAttribute("type", "text");
  formTextInput.setAttribute("placeholder", "API Key");
  formSubmit.setAttribute("type", "button");
  formSubmit.setAttribute("value", "SAVE");
  formSubmit.disabled = true;

  form.appendChild(formTextInput);
  form.appendChild(formSubmit);
  form.appendChild(formKeyStatusText);
  form.appendChild(formDeleteBtn);
  modal.appendChild(form);

  const titleEl = document.querySelector(".title-black");
  container.appendChild(btn);
  container.appendChild(modal);
  titleEl.appendChild(container);

  itemObserver.observe(getCurrentTabContent(), {
    attributes: true,
    childList: true,
    subtree: true,
  });

  const categoryElements =
    document.querySelectorAll("#categoriesList")[0].children;

  titleEl.addEventListener("click", async (e) => {
    if (e.target !== titleEl) return;

    if (
      (await GM_getValue("invsorter", null)) !== null &&
      document.querySelector(".tt-item-price")
    ) {
      alert(
        "Please either delete your API key after clicking the 'IS' button or disable Torn Tools to be able to use Inventory Sorter!"
      );
      return;
    } else if (
      (await GM_getValue("invsorter", null)) === null &&
      !document.querySelector(".tt-item-price")
    ) {
      alert(
        "Please submit your API key after clicking the 'IS' button to be able to use Inventory Sorter!"
      );
      return;
    }

    if (!posBeforeScroll && !tabs[currentTabIndex].isFullyLoaded) {
      posBeforeScroll = window.scrollY;
      await loadTabItems();
    }

    if (tabs[currentTabIndex].isFullyLoaded) {
      sortTab();
    }
  });

  Array.from(categoryElements).forEach((el) => {
    if (el.classList.contains("no-items")) return;

    el.addEventListener("click", (e) => {
      posBeforeScroll = null;
      currentTab = getCurrentTabElement();
      currentTabIndex = getCurrentTabIndex();
      sortState = "default";

      itemObserver.disconnect();
      itemObserver.observe(getCurrentTabContent(), {
        attributes: true,
        childList: true,
        subtree: true,
      });

      recordTab();
    });
  });

  btn.addEventListener("click", async (e) => {
    modal.classList.toggle("hide");
  });

  formSubmit.addEventListener("click", async (e) => {
    e.preventDefault();
    const key = formTextInput.value;
    const url = `https://api.torn.com/key/?key=${key}&selections=info&comment=InvSorter`;

    try {
      const res = await fetch(url);

      if (!res.ok) {
        throw new Error(res.status);
      }

      const data = await res.json();

      if (data.error && data.error.error) {
        formKeyStatusText.textContent = "API Error!";
        return;
      }

      await GM_setValue("invsorter", key);

      formTextInput.value = "";
      formKeyStatusText.textContent = "Key: Saved";
      formDeleteBtn.classList.toggle("hide");
      formSubmit.disabled = true;
    } catch (err) {
      console.error(`Inventory Sorter: ${err.message}`);
    }
  });

  form.addEventListener("submit", (e) => {
    e.preventDefault();
  });

  formTextInput.addEventListener("input", async (e) => {
    if (formTextInput.value === "" && !(await isKeySaved())) {
      formSubmit.disabled = true;
    }

    if (formTextInput.value !== "" && !(await isKeySaved())) {
      formSubmit.disabled = false;
    }
  });

  formDeleteBtn.addEventListener("click", async (e) => {
    await GM_deleteValue("invsorter");

    formTextInput.value = "";
    formKeyStatusText.textContent = "Key: Not Saved";
    formDeleteBtn.classList.toggle("hide");
    formSubmit.disabled = false;
  });

  function recordTab() {
    if (tabs[currentTabIndex]?.isFullyLoaded) return;

    if (tabs[currentTabIndex]) {
      tabs[currentTabIndex].defaultOrder = [...getCurrentTabContent().children];

      if (itemValueSource === "is" && Object.keys(itemValues).length) {
        appendItemValues(tabs[currentTabIndex].defaultOrder);
      }

      return;
    }

    const newTab = {
      [currentTabIndex]: {
        isFullyLoaded: false,
        defaultOrder: [...getCurrentTabContent().children],
      },
    };

    if (itemValueSource === "is" && Object.keys(itemValues).length) {
      appendItemValues(newTab[currentTabIndex].defaultOrder);
    }

    tabs = { ...tabs, ...newTab };
  }

  function getCurrentTabElement() {
    return document.querySelector(".ui-tabs-active");
  }

  function getCurrentTabContent() {
    return document.querySelector('[aria-hidden="false"]');
  }

  function getCurrentTabIndex() {
    return Array.prototype.indexOf.call(
      currentTab.parentNode.children,
      currentTab
    );
  }

  async function fetchApiValues() {
    try {
      const apiKey = await GM_getValue("invsorter", null);
      const url = `https://api.torn.com/torn/?key=${apiKey}&selections=items&comment=InvSorter`;

      const res = await fetch(url);

      if (!res.ok) {
        throw new Error(res);
      }

      const data = await res.json();

      if (data.error && data.error.error) {
        return;
      }

      const { items } = data;

      for (const [id, item] of Object.entries(items)) {
        itemValues[id] = { name: item.name, value: item.market_value };
      }

      appendItemValues(tabs[currentTabIndex].defaultOrder);
    } catch (err) {
      console.error(`Inventory Sorter: ${err.message}`);
    }
  }

  function appendItemValues(defaultElements) {
    Array.from(defaultElements).forEach((el) => {
      if (!el.getAttribute("data-item")) return;

      if (!el.querySelector(".is-item-value")) {
        const nameWrap = el.querySelector(".name-wrap");
        const itemValue = getItemValue(el.getAttribute("data-item"));
        const valueEl = document.createElement("span");
        const totalValueEl = document.createElement("span");
        const qtyEl = el.querySelector(".item-amount");
        const bonusesEl = el.querySelector(".bonuses-wrap");

        valueEl.classList.add("is-item-value-color");
        totalValueEl.classList.add("is-item-value-color");

        let valueContainer;

        if (bonusesEl) {
          valueContainer = document.createElement("li");
          valueContainer.classList.add("is-item-value");
          bonusesEl.appendChild(valueContainer);
        } else {
          valueContainer = document.createElement("span");
          valueContainer.classList.add("is-item-value");
          valueContainer.classList.add("right");
          nameWrap.appendChild(valueContainer);
        }

        if (qtyEl.textContent === "") {
          valueEl.textContent = `${getUsdFormat(itemValue)}`;
          valueContainer.appendChild(valueEl);
        } else {
          valueEl.textContent = `${getUsdFormat(itemValue)} `;

          const itemQty = qtyEl.textContent;
          const newQtyEl = document.createElement("span");
          newQtyEl.classList.add("is-item-qty");
          newQtyEl.textContent = `x ${itemQty} = `;

          totalValueEl.textContent = `${getUsdFormat(itemQty * itemValue)}`;

          valueContainer.appendChild(valueEl);
          valueContainer.appendChild(newQtyEl);
          valueContainer.appendChild(totalValueEl);
        }
      }
    });
  }

  function getItemValue(itemId) {
    return itemValues[itemId]?.value;
  }

  function getUsdFormat(amount) {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      maximumFractionDigits: 0,
    }).format(amount);
  }

  function sortTab() {
    const defaultOrderCopy = [...tabs[currentTabIndex].defaultOrder];

    const tabItems = [];
    for (const item of defaultOrderCopy) {
      if (item.classList.contains("ajax-item-loader")) {
        continue;
      }

      const itemId = item.getAttribute("data-item");
      let itemQty = item.getAttribute("data-qty");

      if (itemValueSource === "is") {
        if (itemQty === "") {
          itemQty = 1;
        }

        tabItems.push({ el: item, value: getItemValue(itemId) * itemQty });
      } else if (itemValueSource === "tt") {
        const value = item
          .querySelector(".tt-item-price")
          .lastChild.textContent.replace(/[^0-9.-]+/g, "");
        tabItems.push({ el: item, value });
      }
    }

    if (sortState === "default") {
      sortState = "descending";
      tabItems.sort((a, b) => b.value - a.value);
      tabItems.forEach((item) => getCurrentTabContent().appendChild(item.el));
    } else if (sortState === "descending") {
      sortState = "ascending";
      tabItems.sort((a, b) => a.value - b.value);
      tabItems.forEach((item) => getCurrentTabContent().appendChild(item.el));
    } else if (sortState === "ascending") {
      sortState = "default";
      defaultOrderCopy.forEach((item) => {
        if (!item.classList.contains("ajax-item-loader")) {
          getCurrentTabContent().appendChild(item);
        }
      });
    }
  }

  async function loadTabItems() {
    const text = document.querySelector("#load-more-items-desc").textContent;

    if (text.toLowerCase().includes("full")) {
      window.scroll(0, posBeforeScroll);
      tabs[currentTabIndex].isFullyLoaded = true;
      return;
    }

    if (text.toLowerCase().includes("load more")) {
      document.querySelector(".items-wrap").lastElementChild.scrollIntoView();
      await new Promise((resolve) => setTimeout(resolve, 500));
      return loadTabItems();
    }
  }

  async function isKeySaved() {
    return (await GM_getValue("invsorter", null)) !== null;
  }
})();