Perfect Circuit Numbered Pagination

Adds a persistent numbered page navigator to Perfect Circuit category pages.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Perfect Circuit Numbered Pagination
// @namespace    local.perfectcircuit.pagination
// @license      MIT
// @version      0.0.3
// @description  Adds a persistent numbered page navigator to Perfect Circuit category pages.
// @match        https://www.perfectcircuit.com/*.html*
// @run-at       document-idle
// @grant        none
// @noframes
// ==/UserScript==

(() => {
  "use strict";

  const HOST_ID = "pc-numbered-pagination-host";
  const COLLAPSED_KEY = "pc-numbered-pagination:collapsed";
  const CACHE = new Map();

  const PAGE_PARAM = "p";
  const LIMIT_PARAM = "product_list_limit";
  const DEFAULT_PER_PAGE = 48;

  function normalizeText(value) {
    return String(value || "")
      .replace(/\u00a0/g, " ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function toNumber(value) {
    const number = Number(String(value || "").replace(/,/g, ""));
    return Number.isFinite(number) ? number : 0;
  }

  function getCurrentPage() {
    const url = new URL(location.href);
    const page = toNumber(url.searchParams.get(PAGE_PARAM));
    return Number.isInteger(page) && page > 0 ? page : 1;
  }

  function getLimitFromUrl() {
    const url = new URL(location.href);
    const limit = toNumber(url.searchParams.get(LIMIT_PARAM));
    return Number.isInteger(limit) && limit > 0 ? limit : null;
  }

  function getBestPerPage(start, end, currentPage) {
    const fromUrl = getLimitFromUrl();
    if (fromUrl) return fromUrl;

    // On page 2+, the first visible item preserves the configured page size,
    // even when the current page is partial.
    if (currentPage > 1 && start > 1) {
      const inferred = Math.round((start - 1) / (currentPage - 1));
      if (Number.isInteger(inferred) && inferred > 0) return inferred;
    }

    const fromRange = end - start + 1;
    return fromRange > 0 ? fromRange : DEFAULT_PER_PAGE;
  }

  function parseCountFromText(text) {
    const clean = normalizeText(text);
    const match = clean.match(
      /(?:\bitems?\s*)?(\d[\d,]*)\s*(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\u2015|to)\s*(\d[\d,]*)\s+of\s+(\d[\d,]*)(?:\s+items?)?/i
    );

    if (!match) return null;

    const start = toNumber(match[1]);
    const end = toNumber(match[2]);
    const totalItems = toNumber(match[3]);
    const currentPage = getCurrentPage();
    const perPage = getBestPerPage(start, end, currentPage);
    const totalPages = Math.ceil(totalItems / perPage);

    if (
      !Number.isInteger(start) ||
      !Number.isInteger(end) ||
      !Number.isInteger(totalItems) ||
      start <= 0 ||
      end < start ||
      totalItems <= 0 ||
      perPage <= 0 ||
      totalPages <= 0
    ) {
      return null;
    }

    return {
      start,
      end,
      totalItems,
      perPage,
      totalPages,
      source: "count"
    };
  }

  function findCountInElement(element) {
    if (!element) return null;

    const ownerDocument = element.ownerDocument || document;
    const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
    let node;

    while ((node = walker.nextNode())) {
      const text = normalizeText(node.nodeValue);
      if (!text || text.length > 160) continue;

      const parsed = parseCountFromText(text);
      if (parsed) return parsed;
    }

    return null;
  }

  function findCountInDom() {
    const scopes = document.querySelectorAll(
      ".toolbar, .toolbar-products, .pages, .page-main, main"
    );

    for (const scope of scopes) {
      const parsed = findCountInElement(scope);
      if (parsed) return parsed;
    }

    return null;
  }

  function parsePageNumberFromAnchor(anchor) {
    try {
      const url = new URL(anchor.href, location.href);
      const fromParam = toNumber(url.searchParams.get(PAGE_PARAM));
      if (Number.isInteger(fromParam) && fromParam > 0) return fromParam;
    } catch {
      return null;
    }

    const label = normalizeText(
      [
        anchor.textContent,
        anchor.getAttribute("aria-label"),
        anchor.getAttribute("title")
      ]
        .filter(Boolean)
        .join(" ")
    );

    const labelMatch = label.match(/\bpage\s+(\d+)\b/i) || label.match(/^(\d+)$/);
    const fromLabel = labelMatch ? toNumber(labelMatch[1]) : 0;

    return Number.isInteger(fromLabel) && fromLabel > 0 ? fromLabel : null;
  }

  function getVisiblePageNumbers() {
    const containers = Array.from(
      document.querySelectorAll(".pages, .pagination, .toolbar, .toolbar-products")
    );

    return containers
      .flatMap((container) => Array.from(container.querySelectorAll("a[href]")))
      .map(parsePageNumberFromAnchor)
      .filter((page) => Number.isInteger(page) && page > 0);
  }

  async function fetchCurrentHtmlInfo() {
    const cacheKey = location.href;
    if (CACHE.has(cacheKey)) return CACHE.get(cacheKey);

    const promise = fetch(location.href, { credentials: "same-origin" })
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      })
      .then((html) => {
        const parsedDocument = new DOMParser().parseFromString(html, "text/html");
        return findCountInElement(parsedDocument.body);
      })
      .catch(() => null);

    CACHE.set(cacheKey, promise);
    return promise;
  }

  async function getPaginationInfo() {
    const domInfo = findCountInDom();
    if (domInfo) return domInfo;

    const htmlInfo = await fetchCurrentHtmlInfo();
    if (htmlInfo) return htmlInfo;

    const visiblePages = getVisiblePageNumbers();
    const maxVisiblePage = visiblePages.length ? Math.max(...visiblePages) : 0;

    if (maxVisiblePage > 1) {
      return {
        start: null,
        end: null,
        totalItems: null,
        perPage: getLimitFromUrl() || DEFAULT_PER_PAGE,
        totalPages: maxVisiblePage,
        source: "visible-links"
      };
    }

    return null;
  }

  function makePageUrl(page) {
    const url = new URL(location.href);

    if (page <= 1) {
      url.searchParams.delete(PAGE_PARAM);
    } else {
      url.searchParams.set(PAGE_PARAM, String(page));
    }

    url.hash = "";
    return url.toString();
  }

  function isCollapsed() {
    try {
      return localStorage.getItem(COLLAPSED_KEY) === "true";
    } catch {
      return false;
    }
  }

  function setCollapsed(value) {
    try {
      localStorage.setItem(COLLAPSED_KEY, value ? "true" : "false");
    } catch {
      // Storage can be unavailable in restricted browsing contexts.
    }
  }

  function pageRange(totalPages, currentPage) {
    if (totalPages <= 13) {
      return Array.from({ length: totalPages }, (_, index) => index + 1);
    }

    const pages = [1, 2, 3, totalPages - 2, totalPages - 1, totalPages];

    for (let page = currentPage - 4; page <= currentPage + 4; page += 1) {
      pages.push(page);
    }

    return Array.from(new Set(pages))
      .filter((page) => page >= 1 && page <= totalPages)
      .sort((a, b) => a - b);
  }

  function buildStyles() {
    return `
      :host {
        all: initial;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      .pc-panel {
        position: fixed;
        left: 16px;
        right: 16px;
        bottom: 16px;
        z-index: 2147483647;
        box-sizing: border-box;
        padding: 12px;
        color: #111;
        background: #fff;
        border: 1px solid #c9c9c9;
        border-radius: 10px;
        box-shadow: 0 8px 28px rgba(0, 0, 0, 0.2);
        font-size: 14px;
        line-height: 1.35;
      }

      .pc-collapsed {
        left: auto;
        right: 16px;
        width: auto;
        padding: 0;
        overflow: hidden;
      }

      .pc-top {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        margin-bottom: 10px;
      }

      .pc-title {
        font-weight: 700;
      }

      .pc-subtitle {
        color: #555;
        font-size: 12px;
        margin-top: 2px;
      }

      .pc-controls {
        display: flex;
        align-items: center;
        gap: 8px;
        flex-wrap: wrap;
      }

      .pc-pages {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        max-height: 28vh;
        overflow: auto;
      }

      .pc-link,
      .pc-button {
        box-sizing: border-box;
        border: 1px solid #bbb;
        border-radius: 7px;
        background: #fff;
        color: #111;
        cursor: pointer;
        font: inherit;
        text-decoration: none;
      }

      .pc-link {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 32px;
        height: 32px;
        padding: 0 8px;
      }

      .pc-link[aria-current="page"] {
        background: #e7e7e7;
        border-color: #888;
        font-weight: 800;
      }

      .pc-link[aria-disabled="true"],
      .pc-button[disabled] {
        cursor: default;
        opacity: 0.45;
      }

      .pc-button {
        padding: 6px 9px;
      }

      .pc-button:not([disabled]):hover,
      .pc-link:not([aria-disabled="true"]):hover {
        background: #f3f3f3;
      }

      .pc-input {
        width: 72px;
        box-sizing: border-box;
        border: 1px solid #aaa;
        border-radius: 7px;
        padding: 6px 8px;
        font: inherit;
      }

      .pc-ellipsis {
        display: inline-flex;
        align-items: center;
        height: 32px;
        color: #555;
      }

      .pc-open {
        padding: 9px 12px;
        font-weight: 700;
      }

      @media (max-width: 700px) {
        .pc-panel {
          left: 8px;
          right: 8px;
          bottom: 8px;
        }

        .pc-top {
          align-items: flex-start;
          flex-direction: column;
        }
      }
    `;
  }

  function addPageLink(container, page, currentPage, label) {
    const link = document.createElement("a");
    link.className = "pc-link";
    link.href = makePageUrl(page);
    link.textContent = label || String(page);
    link.setAttribute("aria-label", label ? `Go to ${label.toLowerCase()} page` : `Go to page ${page}`);

    if (page === currentPage && !label) {
      link.setAttribute("aria-current", "page");
    }

    container.appendChild(link);
  }

  function addDisabledLink(container, label) {
    const link = document.createElement("a");
    link.className = "pc-link";
    link.textContent = label;
    link.setAttribute("aria-disabled", "true");
    link.tabIndex = -1;
    link.addEventListener("click", (event) => event.preventDefault());
    container.appendChild(link);
  }

  function addEllipsis(container) {
    const span = document.createElement("span");
    span.className = "pc-ellipsis";
    span.textContent = "...";
    container.appendChild(span);
  }

  function renderExpanded(root, info) {
    const currentPage = Math.min(getCurrentPage(), info.totalPages);

    const panel = document.createElement("section");
    panel.className = "pc-panel";
    panel.setAttribute("aria-label", "Perfect Circuit numbered pagination");

    const top = document.createElement("div");
    top.className = "pc-top";

    const titleWrap = document.createElement("div");

    const title = document.createElement("div");
    title.className = "pc-title";
    title.textContent = `Page ${currentPage} of ${info.totalPages}`;

    const subtitle = document.createElement("div");
    subtitle.className = "pc-subtitle";
    subtitle.textContent = info.totalItems
      ? `${info.totalItems.toLocaleString()} items, ${info.perPage} per page`
      : "Using visible page links only";

    titleWrap.append(title, subtitle);

    const controls = document.createElement("form");
    controls.className = "pc-controls";

    if (currentPage > 1) {
      addPageLink(controls, currentPage - 1, currentPage, "Previous");
    } else {
      addDisabledLink(controls, "Previous");
    }

    if (currentPage < info.totalPages) {
      addPageLink(controls, currentPage + 1, currentPage, "Next");
    } else {
      addDisabledLink(controls, "Next");
    }

    const input = document.createElement("input");
    input.className = "pc-input";
    input.type = "number";
    input.inputMode = "numeric";
    input.min = "1";
    input.max = String(info.totalPages);
    input.placeholder = "Page";

    const jump = document.createElement("button");
    jump.className = "pc-button";
    jump.type = "submit";
    jump.textContent = "Go";

    controls.addEventListener("submit", (event) => {
      event.preventDefault();

      const page = toNumber(input.value);
      if (!Number.isInteger(page) || page < 1 || page > info.totalPages) return;

      location.href = makePageUrl(page);
    });

    const collapse = document.createElement("button");
    collapse.className = "pc-button";
    collapse.type = "button";
    collapse.textContent = "Hide";
    collapse.addEventListener("click", () => {
      setCollapsed(true);
      render(info);
    });

    controls.append(input, jump, collapse);
    top.append(titleWrap, controls);

    const pages = document.createElement("nav");
    pages.className = "pc-pages";
    pages.setAttribute("aria-label", "Page numbers");

    const range = pageRange(info.totalPages, currentPage);
    let previousPage = 0;

    for (const page of range) {
      if (previousPage && page > previousPage + 1) addEllipsis(pages);
      addPageLink(pages, page, currentPage);
      previousPage = page;
    }

    panel.append(top, pages);
    root.appendChild(panel);
  }

  function renderCollapsed(root, info) {
    const panel = document.createElement("section");
    panel.className = "pc-panel pc-collapsed";

    const open = document.createElement("button");
    open.className = "pc-button pc-open";
    open.type = "button";
    open.textContent = `Page ${getCurrentPage()} / ${info.totalPages}`;
    open.addEventListener("click", () => {
      setCollapsed(false);
      render(info);
    });

    panel.appendChild(open);
    root.appendChild(panel);
  }

  function render(info) {
    document.getElementById(HOST_ID)?.remove();

    if (!info || info.totalPages <= 1) return;

    const host = document.createElement("div");
    host.id = HOST_ID;

    const shadow = host.attachShadow({ mode: "open" });

    const style = document.createElement("style");
    style.textContent = buildStyles();

    shadow.appendChild(style);

    if (isCollapsed()) {
      renderCollapsed(shadow, info);
    } else {
      renderExpanded(shadow, info);
    }

    document.body.appendChild(host);
  }

  async function tryRender() {
    const info = await getPaginationInfo();
    render(info);
    return Boolean(info);
  }

  async function boot() {
    if (await tryRender()) return;

    let attempts = 0;
    const maxAttempts = 8;
    let pending = false;

    const timer = setInterval(async () => {
      if (pending) return;

      pending = true;
      attempts += 1;

      try {
        if ((await tryRender()) || attempts >= maxAttempts) {
          clearInterval(timer);
        }
      } finally {
        pending = false;
      }
    }, 500);
  }

  boot();
})();