Perfect Circuit Numbered Pagination

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();