Xbox-Now Enhanced Filters

Enhanced Xbox-Now Filters with advanced pillbox UI for a seamless and intuitive experience

// ==UserScript==
// @name         Xbox-Now Enhanced Filters
// @namespace    https://jlcareglio.github.io/
// @version      3.6.1
// @description  Enhanced Xbox-Now Filters with advanced pillbox UI for a seamless and intuitive experience
// @author       Jesús Lautaro Careglio Albornoz
// @source       https://gist.githubusercontent.com/JLCareglio/1e4b0838bdf31e21ed749cfcd89a3a47/raw/01_Xbox-Now-Enhanced-Filters.user.js
// @match        *://*.xbox-now.com/*
// @license      MIT
// @compatible   firefox
// @compatible   chrome
// @compatible   opera
// @compatible   safari
// @compatible   edge
// @compatible   brave
// @icon         https://www.google.com/s2/favicons?sz=64&domain=xbox-now.com
// @grant        none
// @supportURL   https://gist.github.com/JLCareglio/1e4b0838bdf31e21ed749cfcd89a3a47
// @require      https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4
// ==/UserScript==

(() => {
  "use strict";

  const style = document.createElement("style");
  style.textContent = `
    .collapse { visibility: visible !important; }
    @keyframes fadeIn {
      from { opacity: 0; transform: scale(0.95); }
      to { opacity: 1; transform: scale(1); }
    }
    .animate-fade-in {
      animation: fadeIn 0.2s ease-out;
    }
    @keyframes highlight-fade {
      from { background-color: #dcfce7; }
      to { background-color: #e5e7eb; }
    }
    .animate-highlight {
      animation: highlight-fade 1.5s ease-out forwards;
    }
  `;
  document.head.appendChild(style);

  const hideSVG =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8/10 h-8/10"><path d="M3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18ZM22.676 12.553a11.249 11.249 0 0 1-2.631 4.31l-3.099-3.099a5.25 5.25 0 0 0-6.71-6.71L7.759 4.577a11.217 11.217 0 0 1 4.242-.827c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113Z" /><path d="M15.75 12c0 .18-.013.357-.037.53l-4.244-4.243A3.75 3.75 0 0 1 15.75 12ZM12.53 15.713l-4.243-4.244a3.75 3.75 0 0 0 4.244 4.243Z" /><path d="M6.75 12c0-.619.107-1.213.304-1.764l-3.1-3.1a11.25 11.25 0 0 0-2.63 4.31c-.12.362-.12.752 0 1.114 1.489 4.467 5.704 7.69 10.675 7.69 1.5 0 2.933-.294 4.242-.827l-2.477-2.477A5.25 5.25 0 0 1 6.75 12Z" /></svg>';
  const showSVG =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8/10 h-8/10"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" /><path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" /></svg>';
  const filterSVG =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8/10 h-8/10"><path fill-rule="evenodd" d="M3.792 2.938A49.069 49.069 0 0 1 12 2.25c2.797 0 5.54.236 8.209.688a1.857 1.857 0 0 1 1.541 1.836v1.044a3 3 0 0 1-.879 2.121l-6.182 6.182a1.5 1.5 0 0 0-.439 1.061v2.927a3 3 0 0 1-1.658 2.684l-1.757.878A.75.75 0 0 1 9.75 21v-5.818a1.5 1.5 0 0 0-.44-1.06L3.13 7.938a3 3 0 0 1-.879-2.121V4.774c0-.897.64-1.683 1.542-1.836Z" clip-rule="evenodd" /></svg>';
  const minimizeSVG =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-9 h-9"><path fill-rule="evenodd" d="M12.53 16.28a.75.75 0 0 1-1.06 0l-7.5-7.5a.75.75 0 0 1 1.06-1.06L12 14.69l6.97-6.97a.75.75 0 1 1 1.06 1.06l-7.5 7.5Z" clip-rule="evenodd" /></svg>';
  const cancelSVG =
    '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-9 h-9"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>';

  const defaultFilters = {
    includeTags: { enabled: true, tags: ["PC", "GAME PASS"] },
    excludeTags: { enabled: false, tags: [] },
    hiddenGames: { enabled: true, games: [] },
  };

  const processStoredFilters = () => {
    const storedFilters = localStorage.getItem("filters");
    if (!storedFilters) return defaultFilters;

    try {
      const parsed = JSON.parse(storedFilters);
      ["includeTags", "excludeTags", "hiddenGames"].forEach((key) => {
        if (parsed[key] && parsed[key].enabled !== undefined) {
          if (Array.isArray(parsed[key].tags))
            parsed[key].tags = parsed[key].tags.filter(
              (tag) => typeof tag === "string"
            );
          else if (parsed[key].games)
            parsed[key].games = parsed[key].games.filter(
              (game) => typeof game === "string"
            );
        } else parsed[key] = { ...defaultFilters[key] };
      });
      return parsed;
    } catch (e) {
      console.error("Error al procesar los filtros guardados:", e);
      return { ...defaultFilters };
    }
  };

  const filters = processStoredFilters();

  function filterGames() {
    document
      .querySelectorAll(".box-body.comparison-table-entry")
      .forEach((gameEntry) => {
        const storeButton = gameEntry.querySelector("a.btn");
        if (!storeButton) return;

        const labels = gameEntry.querySelectorAll(".label");
        const hasTags = (tags) =>
          Array.from(labels).some((label) =>
            tags.some((tag) =>
              label.textContent.toUpperCase().includes(tag.toUpperCase())
            )
          );

        const isIncluded =
          !filters.includeTags.enabled ||
          filters.includeTags.tags.length === 0 ||
          hasTags(filters.includeTags.tags);

        const isExcluded =
          filters.excludeTags.enabled &&
          filters.excludeTags.tags.length > 0 &&
          hasTags(filters.excludeTags.tags);

        const isHidden =
          filters.hiddenGames.enabled &&
          filters.hiddenGames.games.includes(storeButton.title);

        const shouldBeVisible = isIncluded && !isExcluded && !isHidden;
        gameEntry.style.display = shouldBeVisible ? "" : "none";
      });
  }

  document
    .querySelectorAll(".box-body.comparison-table-entry")
    .forEach((gameEntry) => {
      const storeButton = gameEntry.querySelector("a.btn");
      const btnHide = document.createElement("button");
      const btnConfig = document.createElement("button");
      const btnContainer = document.createElement("div");
      const gameName = storeButton.title;
      const isHidden = filters.hiddenGames.games.includes(gameName);
      btnHide.innerHTML = isHidden ? showSVG : hideSVG;
      btnHide.className =
        "flex-1 flex items-center justify-center h-full p-0 m-0 hover:bg-[#8d0040]";
      btnConfig.innerHTML = filterSVG;
      btnConfig.className =
        "flex-1 flex items-center justify-center h-full p-0 m-0 hover:bg-[#8d0040]";
      btnContainer.className =
        "text-white bg-[#a6004c] border border-[#8d0040] mt-2 flex h-[34px] w-full overflow-hidden [&>button:not(:last-child)]:border-r [&>button:not(:last-child)]:border-r-white";
      btnContainer.appendChild(btnHide);
      btnContainer.appendChild(btnConfig);
      storeButton.parentNode.appendChild(btnContainer);

      btnHide.addEventListener("click", () => {
        const gameName = storeButton.title;
        const gameIndex = filters.hiddenGames.games.indexOf(gameName);

        if (gameIndex > -1) {
          filters.hiddenGames.games.splice(gameIndex, 1);
          btnHide.innerHTML = hideSVG;
        } else {
          filters.hiddenGames.games.push(gameName);
          btnHide.innerHTML = showSVG;
        }

        localStorage.setItem("filters", JSON.stringify(filters));

        const hiddenGamesTextarea =
          document.getElementById("hiddenGames-values");
        if (hiddenGamesTextarea) {
          hiddenGamesTextarea.value = filters.hiddenGames.games
            .map(escapeCommas)
            .join(",");
          hiddenGamesTextarea.dispatchEvent(new CustomEvent("renderpills"));
        }

        filterGames();
      });

      btnConfig.addEventListener("click", () => {
        const modal = document.getElementById("medium-modal");
        modal.classList.remove("hidden");
      });
    });

  function createFilterSection(id, title, description, placeholder) {
    const values = filters[id].tags || filters[id].games || [];
    const textareaValue = Array.isArray(values) ? values.join(", ") : "";

    return `
      <div class="bg-white rounded-xl shadow-sm p-4 border border-slate-200/80">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-3 cursor-pointer" onclick="document.getElementById('${id}-enabled').click()">
            <h4 class="text-base font-semibold text-slate-800">${title}</h4>
          </div>
          <label class="relative inline-flex items-center cursor-pointer mb-0!">
            <input type="checkbox" id="${id}-enabled" class="sr-only peer" ${
      filters[id].enabled ? "checked" : ""
    }>
            <div class="w-15 h-9 bg-slate-200 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#a6004c]/30 peer-checked:after:translate-x-5.5 peer-checked:after:border-white after:content-[''] after:absolute after:top-[1.3px] after:left-[2.3px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-8 after:w-8 after:transition-all peer-checked:bg-[#a6004c]"></div>
          </label>
        </div>
        <p class="text-sm text-slate-500 mt-1 mb-3 font-normal">${description}</p>
        
        <div id="${id}-pills-container" class="w-full p-2.5 border border-slate-300 rounded-lg min-h-24 text-sm bg-slate-50 focus-within:ring-2 focus-within:ring-[#a6004c]/50 focus-within:border-[#a6004c] transition flex flex-wrap gap-2 items-start content-start overflow-y-auto">
          <!-- Pills will be generated here -->
        </div>

        <textarea 
          id="${id}-values" 
          class="w-full p-2.5 border border-slate-300 rounded-lg h-24 text-sm bg-slate-50 focus:ring-2 focus:ring-[#a6004c]/50 focus:border-[#a6004c] transition hidden"
          placeholder="${placeholder}"
        >${textareaValue}</textarea>
      </div>
    `;
  }

  function initializePillInputs() {
    ["includeTags", "excludeTags", "hiddenGames"].forEach((id) => {
      const pillsContainer = document.getElementById(`${id}-pills-container`);
      const textarea = document.getElementById(`${id}-values`);
      let isEditing = false;
      let lastAdded = [];
      let renderPills;

      const toTextareaView = () => {
        if (isEditing) return;
        pillsContainer.classList.add("hidden");
        textarea.classList.remove("hidden");
        if (
          textarea.value.trim().length > 0 &&
          !textarea.value.trim().endsWith(",")
        )
          textarea.value += ", ";

        textarea.focus();
        textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
      };

      const createPill = (value) => {
        const pill = document.createElement("span");
        pill.className =
          "inline-flex items-center bg-slate-200 text-slate-800 text-sm font-medium rounded-full overflow-hidden animate-fade-in";
        pill.onclick = (e) => e.stopPropagation();

        if (lastAdded.includes(value)) pill.classList.add("animate-highlight");

        const pillText = document.createElement("span");
        pillText.textContent = value;
        pillText.className =
          "cursor-pointer pl-2.5 pr-2 py-1 hover:bg-slate-300/60 transition-colors";
        pillText.onclick = (e) => {
          e.stopPropagation();
          editPill(pill);
        };

        const deleteBtn = document.createElement("button");
        deleteBtn.type = "button";
        deleteBtn.className =
          "px-1.5 py-1 border-l border-slate-400/20 text-slate-500 hover:text-[#a6004c] hover:bg-slate-300/60 transition-colors self-stretch flex items-center";
        deleteBtn.innerHTML = cancelSVG.replace("w-9 h-9", "w-4 h-4");
        deleteBtn.onclick = (e) => {
          e.stopPropagation();
          const currentValues = textarea.value.split(",").map((v) => v.trim());
          const index = currentValues.indexOf(value);
          if (index > -1) {
            currentValues.splice(index, 1);
            textarea.value = currentValues.join(", ");
          }
          renderPills();
        };

        pill.appendChild(pillText);
        pill.appendChild(deleteBtn);
        return pill;
      };

      const editPill = (pill) => {
        isEditing = true;
        const pillText = pill.querySelector("span");
        const deleteBtn = pill.querySelector("button");
        const currentValue = pillText.textContent;

        const input = document.createElement("input");
        input.type = "text";
        input.value = currentValue;
        input.className =
          "bg-transparent focus:outline-none w-auto py-1 pl-2.5 pr-2";
        input.style.width = `${currentValue.length + 3}ch`;
        input.onclick = (e) => e.stopPropagation();

        pillText.classList.add("hidden");
        deleteBtn.classList.add("hidden");
        pill.insertBefore(input, deleteBtn);

        input.focus();

        const saveChanges = () => {
          isEditing = false;
          const newValue = input.value.trim().toUpperCase();
          const currentValues = textarea.value.split(",").map((v) => v.trim());
          const index = currentValues.indexOf(currentValue);

          if (newValue && newValue !== currentValue) {
            if (index > -1) {
              currentValues[index] = newValue;
              textarea.value = currentValues.join(", ");
            }
          } else if (!newValue) {
            if (index > -1) {
              currentValues.splice(index, 1);
              textarea.value = currentValues.join(", ");
            }
          }
          renderPills();
        };

        input.onblur = saveChanges;
        input.onkeydown = (e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            saveChanges();
          } else if (e.key === "Escape") {
            e.preventDefault();
            renderPills();
            isEditing = false;
          }
        };
      };

      const escapeCommas = (str) => str.replace(/,/g, "\\,");
      const unescapeCommas = (str) => str.replace(/\\,/g, ",");
      const splitByUnescapedCommas = (str) => {
        return str
          .split(/(?<!\\),/)
          .map((s) => s.trim())
          .filter((s) => s.length > 0)
          .map((s) => s.replace(/\\,/g, ","));
      };

      const addValues = (inputValue) => {
        const normalizedInput = inputValue.replace(/\s*,\s*/g, ",");
        const newValues = splitByUnescapedCommas(normalizedInput)
          .map((v) => v.trim())
          .filter((v) => v);

        if (newValues.length > 0) {
          const currentValues = textarea.value
            ? splitByUnescapedCommas(textarea.value)
            : [];

          lastAdded = newValues.filter(
            (v) => !currentValues.some((cv) => cv === v)
          );

          const combined = [...new Set([...currentValues, ...newValues])];
          textarea.value = combined.map(escapeCommas).join(",");
        }
        renderPills();
      };

      const createAddInput = () => {
        const addInput = document.createElement("input");
        addInput.type = "text";
        addInput.placeholder = "+ Add";
        addInput.className =
          "bg-transparent focus:outline-none text-sm p-1 w-20 animate-fade-in";
        addInput.onclick = (e) => e.stopPropagation();

        addInput.onkeydown = (e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            addValues(addInput.value);
          }
        };

        addInput.onpaste = (e) => {
          e.preventDefault();
          const pasteData = (e.clipboardData || window.clipboardData).getData(
            "text"
          );
          addValues(pasteData);
        };

        return addInput;
      };

      renderPills = () => {
        let values = textarea.value.trim()
          ? splitByUnescapedCommas(textarea.value)
          : [];

        const uniqueValues = [...new Set(values)];
        const itemsToSort = uniqueValues.map((value) => ({
          original: value,
          normalized: value.toUpperCase(),
        }));

        itemsToSort.sort((a, b) => a.normalized.localeCompare(b.normalized));
        values = itemsToSort.map((item) => item.original);
        textarea.value = values.map(escapeCommas).join(",");

        pillsContainer.innerHTML = "";
        let firstNewPill = null;
        values.forEach((value) => {
          const pill = createPill(value);
          pillsContainer.appendChild(pill);
          if (lastAdded.some((added) => added === value) && !firstNewPill) {
            firstNewPill = pill;
          }
        });

        if (firstNewPill) firstNewPill.querySelector("span").focus();

        lastAdded = [];

        const addInput = createAddInput();
        pillsContainer.appendChild(addInput);

        textarea.classList.add("hidden");
        pillsContainer.classList.remove("hidden");
      };

      pillsContainer.addEventListener("click", toTextareaView);
      textarea.addEventListener("blur", renderPills);
      textarea.addEventListener("renderpills", renderPills);
      textarea.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          const beforeValues = textarea.value.trim()
            ? splitByUnescapedCommas(textarea.value).map((v) => v.trim())
            : [];

          renderPills();

          const afterValues = textarea.value.trim()
            ? splitByUnescapedCommas(textarea.value).map((v) => v.trim())
            : [];

          lastAdded = afterValues.filter((v) => !beforeValues.includes(v));
        }
      });

      renderPills();
    });
  }

  const modalHTML = `
    <div id="medium-modal" class="fixed inset-0 z-1031 hidden">
      <div class="min-h-screen w-full flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" data-modal-hide>
        <div class="w-full max-w-2xl bg-slate-100 rounded-2xl shadow-2xl flex flex-col max-h-[77vh] overflow-hidden border border-slate-200/80">
          <div class="relative p-5 border-b border-slate-200/80">
            <h3 class="text-lg font-semibold text-slate-800 text-center">Enhanced Filter Settings</h3>
            <div class="absolute top-3.5 right-3.5 flex gap-1">
              <button type="button" class="p-2 text-slate-500 hover:text-[#a6004c] transition-colors rounded-full hover:bg-slate-200/70" data-modal-hide aria-label="Minimize Filter Settings">
                ${minimizeSVG}
              </button>
              <button type="button" class="p-2 text-slate-500 hover:text-[#a6004c] transition-colors rounded-full hover:bg-slate-200/70" data-discard-filters data-modal-hide aria-label="Discard Changes">
                ${cancelSVG}
              </button>
            </div>
          </div>
          <div class="flex-1 overflow-y-auto p-4 bg-slate-50">
            <div class="space-y-4">
              ${createFilterSection(
                "includeTags",
                "Include Games with Tags",
                "Only show games that have at least one of these tags.",
                "Examples: PC, GAME PASS, XSX Optimized, etc."
              )}
              ${createFilterSection(
                "excludeTags",
                "Exclude Games with Tags",
                "Hide games that have any of these tags.",
                "Examples: Preorder, EA, GAME PASS, etc"
              )}
              ${createFilterSection(
                "hiddenGames",
                "Hidden Games",
                "Games that will remain hidden from view.",
                "Examples: EA SPORTS, F1, NBA, etc."
              )}
            </div>
          </div>
          <div class="flex justify-center gap-3 p-4 bg-slate-100 border-t border-slate-200/80">
            <button id="cancel-filters" type="button" data-discard-filters data-modal-hide class="px-5 py-2.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-100 focus:ring-4 focus:outline-none focus:ring-slate-200 rounded-lg transition-colors">
              Discard
            </button>
            <button id="apply-filters" type="button" class="px-5 py-2.5 text-sm font-medium text-white! bg-[#a6004c] hover:bg-[#8d0040] focus:ring-4 focus:outline-none focus:ring-[#a6004c]/50 rounded-lg transition-colors">
              Apply Filters
            </button>
          </div>
        </div>
      </div>
    </div>`;

  document.body.insertAdjacentHTML("beforeend", modalHTML);
  initializePillInputs();

  const splitByUnescapedCommas = (str) => {
    if (!str) return [];
    return str
      .split(/(?<!\\),/)
      .map((s) => s.trim())
      .filter((s) => s.length > 0)
      .map((s) => s.replace(/\\,/g, ","));
  };

  const escapeCommas = (str) => str.replace(/,/g, "\\,");

  function restoreInitialFilters() {
    let initialFilters = JSON.parse(JSON.stringify(filters));
    ["includeTags", "excludeTags", "hiddenGames"].forEach((id) => {
      const filter = initialFilters[id];
      document.getElementById(`${id}-enabled`).checked = filter.enabled;
      const textarea = document.getElementById(`${id}-values`);
      const values = filter.tags || filter.games || [];
      textarea.value = values.map(escapeCommas).join(",");
      textarea.dispatchEvent(new CustomEvent("renderpills"));
    });
  }

  document.querySelectorAll("[data-modal-hide]").forEach((element) => {
    element.addEventListener("click", (e) => {
      if (
        e.target === element ||
        e.target.tagName === "svg" ||
        e.target.tagName === "path"
      ) {
        e.preventDefault();
        document.getElementById("medium-modal").classList.add("hidden");
      }
    });
  });

  document.querySelectorAll("[data-discard-filters]").forEach((element) => {
    element.addEventListener("click", (e) => {
      if (
        e.target === element ||
        e.target.tagName === "svg" ||
        e.target.tagName === "path"
      ) {
        e.preventDefault();
        restoreInitialFilters();
      }
    });
  });

  document.getElementById("apply-filters")?.addEventListener("click", () => {
    ["includeTags", "excludeTags", "hiddenGames"].forEach((id) => {
      const enabled = document.getElementById(`${id}-enabled`).checked;
      const textarea = document.getElementById(`${id}-values`);
      const values = splitByUnescapedCommas(textarea.value);

      filters[id] = {
        enabled,
        [id === "hiddenGames" ? "games" : "tags"]: values,
      };
    });

    localStorage.setItem("filters", JSON.stringify(filters));
    document.getElementById("medium-modal").classList.add("hidden");
    filterGames();
  });

  filterGames();

  const filterButton = document.querySelector('a[href="#filterCollapse"]');
  if (!filterButton) return;
  const originalRow = filterButton.closest(".row");
  if (!originalRow) return;
  const originalText = filterButton.querySelector("span").textContent.trim();

  const col = originalRow.querySelector(".col-md-6");
  if (col) col.classList.replace("col-md-6", "col-md-3");

  const newRow = document.createElement("div");
  newRow.className = "col-md-3 col-xs-12 input-group px-[15px]! pb-[15px]!";

  const icon = document.createElement("div");
  icon.className = "input-group-addon w-[38px]!";
  icon.innerHTML = filterSVG;
  icon.firstElementChild.setAttribute("class", "w-full h-full");

  const link = document.createElement("a");
  link.style.cssText =
    "padding-bottom: 5px; text-align: left; cursor: pointer;";
  link.className = "btn btn-white btn-block btn-flat";
  link.innerHTML = `<span>${originalText} +</span>`;
  link.addEventListener("click", () => {
    const modal = document.getElementById("medium-modal");
    ["includeTags", "excludeTags", "hiddenGames"].forEach((id) => {
      const filter = filters[id];
      const values = id === "hiddenGames" ? filter.games : filter.tags;
      const textarea = document.getElementById(`${id}-values`);
      if (textarea) {
        textarea.value = values.map(escapeCommas).join(",");
        textarea.dispatchEvent(new CustomEvent("renderpills"));
      }
    });
    modal.classList.remove("hidden");
  });

  newRow.appendChild(icon);
  newRow.appendChild(link);
  originalRow.appendChild(newRow);
})();