Camerax Floating Live Multi-Term Search + Price Sort (Stable)

Stable: floating purple search, multi-term partial AND filtering, re-sorts visible items by price, preserves grid without assumptions about wrapper classes.

目前為 2025-09-25 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Camerax Floating Live Multi-Term Search + Price Sort (Stable)
// @namespace    http://tampermonkey.net/
// @version      2.21
// @description  Stable: floating purple search, multi-term partial AND filtering, re-sorts visible items by price, preserves grid without assumptions about wrapper classes.
// @match        https://camerax.com/product-category/*
// @grant        none
// @license GNU GPLv3 
// ==/UserScript==

(function() {
  'use strict';

  const FLOAT_WIDTH = '320px';
  const PURPLE = '#b200ff';
  const DEBOUNCE_MS = 160;

  function log(...args) { try { console.log('[CamX-userscript]', ...args); } catch (e) {} }

  function debounce(fn, ms) {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), ms);
    };
  }

  function findProductsContainer() {
    return document.querySelector('.products')
      || document.querySelector('.products.row')
      || document.querySelector('ul.products')
      || document.querySelector('#main .content')
      || document.querySelector('.shop-container')
      || null;
  }

  // For each product element (.product-small), find the wrapper that is a direct child of container.
  // This preserves the exact structure the theme expects.
  function collectProductEntries(container) {
    if (!container) return [];
    const productEls = Array.from(container.querySelectorAll('.product-small'));
    const entries = productEls.map(productEl => {
      // climb ancestors until we reach an element whose parent is the container (or we stop at root)
      let wrapper = productEl;
      try {
        while (wrapper.parentElement && wrapper.parentElement !== container && wrapper !== document.body) {
          wrapper = wrapper.parentElement;
        }
      } catch (e) { /* defensive */ }

      // If we didn't find a wrapper whose parent is container, fallback to the productEl itself
      if (!wrapper || wrapper.parentElement !== container) {
        wrapper = productEl;
      }

      return { productEl, wrapperEl: wrapper };
    });

    // Remove duplicates (in case multiple product-small select the same wrapper)
    const seen = new Set();
    const dedup = [];
    for (const e of entries) {
      if (!seen.has(e.wrapperEl)) {
        seen.add(e.wrapperEl);
        dedup.push(e);
      }
    }
    return dedup;
  }

  // Parse numeric price from wrapper: take last .woocommerce-Price-amount (handles ins/del)
  function parsePriceFromWrapper(el) {
    if (!el) return Infinity;
    try {
      const amounts = Array.from(el.querySelectorAll('.woocommerce-Price-amount'));
      if (amounts.length === 0) return Infinity;
      const node = amounts[amounts.length - 1];
      const raw = (node.textContent || '').replace(/[^0-9.]/g, '');
      const n = parseFloat(raw);
      return Number.isFinite(n) ? n : Infinity;
    } catch (e) {
      return Infinity;
    }
  }

  function getTitleAndMeta(entry) {
    try {
      const titleNode = entry.productEl.querySelector('.product-title a, .woocommerce-loop-product__title, .name.product-title');
      const title = (titleNode && titleNode.textContent || '').trim();
      const condNode = entry.productEl.querySelector('.condition-grade-shop, .condition-grade, .product-info .stock, .price-wrapper, .product-meta');
      const condText = condNode ? condNode.textContent.trim() : '';
      return (title + ' ' + condText).replace(/\s+/g, ' ').trim();
    } catch (e) {
      return '';
    }
  }

  // filter & resort: always re-collect entries to stay in sync with dynamic changes
  function filterAndResort(container, desc = false, query = '') {
    if (!container) return;
    try {
      const entries = collectProductEntries(container);

      const tokens = (query || '').toLowerCase().trim().split(/\s+/).filter(t => t.length > 0);

      // Show/hide wrappers based on tokens (AND semantics)
      entries.forEach(entry => {
        const text = (getTitleAndMeta(entry) || '').toLowerCase();
        let visible = true;
        for (const tok of tokens) {
          if (!text.includes(tok)) { visible = false; break; }
        }
        // Use inline style display to hide wrapper
        try { entry.wrapperEl.style.display = visible ? '' : 'none'; } catch (e) {}
      });

      // Collect visible wrappers and sort them by price
      const visible = entries
        .filter(e => e.wrapperEl && e.wrapperEl.offsetParent !== null) // visible in layout
        .sort((a, b) => {
          const pa = parsePriceFromWrapper(a.wrapperEl);
          const pb = parsePriceFromWrapper(b.wrapperEl);
          return desc ? pb - pa : pa - pb;
        });

      // Re-append visible wrappers in sorted order (preserves existing nodes/classes)
      visible.forEach(e => {
        try { container.appendChild(e.wrapperEl); } catch (err) {}
      });
    } catch (err) {
      log('filterAndResort error', err);
    }
  }

  function createFloatingUI(onChangeCallback) {
    // Avoid creating duplicate UI
    if (document.getElementById('camx-floating-filter')) {
      const existingInput = document.querySelector('#camx-floating-filter input[type="search"]');
      return { root: document.getElementById('camx-floating-filter'), input: existingInput, getDesc: () => document.getElementById('camx-toggle')?.dataset.desc === '1' };
    }

    const wrap = document.createElement('div');
    wrap.id = 'camx-floating-filter';
    wrap.style.position = 'fixed';
    wrap.style.top = '18px';
    wrap.style.right = '18px';
    wrap.style.zIndex = '999999';
    wrap.style.width = FLOAT_WIDTH;
    wrap.style.maxWidth = '46vw';
    wrap.style.background = PURPLE;
    wrap.style.padding = '12px';
    wrap.style.borderRadius = '10px';
    wrap.style.boxShadow = '0 6px 18px rgba(0,0,0,0.25)';
    wrap.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial';
    wrap.style.color = '#fff';
    wrap.style.display = 'flex';
    wrap.style.flexDirection = 'column';
    wrap.style.gap = '8px';
    wrap.style.alignItems = 'stretch';

    const header = document.createElement('div');
    header.style.display = 'flex';
    header.style.justifyContent = 'space-between';
    header.style.alignItems = 'center';
    header.style.gap = '8px';

    const title = document.createElement('div');
    title.textContent = 'Filter & Sort';
    title.style.fontWeight = '700';
    title.style.fontSize = '13px';

    const small = document.createElement('div');
    small.style.fontSize = '11px';
    small.textContent = 'price';
    small.style.opacity = '0.95';

    header.appendChild(title);
    header.appendChild(small);
    wrap.appendChild(header);

    const input = document.createElement('input');
    input.type = 'search';
    input.placeholder = 'space-separated terms (AND). partial words OK';
    input.style.padding = '8px';
    input.style.fontSize = '14px';
    input.style.border = '2px solid rgba(255,255,255,0.95)';
    input.style.borderRadius = '6px';
    input.style.background = PURPLE;
    input.style.color = '#fff';
    input.style.outline = 'none';
    input.style.width = '100%';
    input.autocomplete = 'off';

    const controls = document.createElement('div');
    controls.style.display = 'flex';
    controls.style.gap = '6px';
    controls.style.alignItems = 'center';

    const toggle = document.createElement('button');
    toggle.id = 'camx-toggle';
    toggle.dataset.desc = '0';
    toggle.textContent = 'Asc';
    toggle.title = 'Toggle Asc/Desc';
    toggle.style.flex = '1';
    toggle.style.padding = '7px 8px';
    toggle.style.border = 'none';
    toggle.style.borderRadius = '6px';
    toggle.style.background = '#fff';
    toggle.style.color = PURPLE;
    toggle.style.fontWeight = '600';
    toggle.style.cursor = 'pointer';

    const clear = document.createElement('button');
    clear.textContent = 'Clear';
    clear.title = 'Clear filter';
    clear.style.padding = '7px 8px';
    clear.style.border = 'none';
    clear.style.borderRadius = '6px';
    clear.style.background = 'rgba(255,255,255,0.15)';
    clear.style.color = '#fff';
    clear.style.cursor = 'pointer';

    controls.appendChild(toggle);
    controls.appendChild(clear);

    wrap.appendChild(input);
    wrap.appendChild(controls);

    document.body.appendChild(wrap);

    input.addEventListener('focus', () => input.style.boxShadow = '0 0 0 4px rgba(255,255,255,0.06)');

    let desc = false;
    toggle.addEventListener('click', () => {
      desc = !desc;
      toggle.dataset.desc = desc ? '1' : '0';
      toggle.textContent = desc ? 'Desc' : 'Asc';
      onChangeCallback(input.value, desc);
    });

    clear.addEventListener('click', () => {
      input.value = '';
      onChangeCallback('', desc);
      input.focus();
    });

    input.addEventListener('input', debounce(() => onChangeCallback(input.value, desc), DEBOUNCE_MS));

    return { root: wrap, input, toggle, clear, getDesc: () => toggle.dataset.desc === '1' };
  }

  function initWhenReady(retries = 50) {
    const container = findProductsContainer();
    if (container) {
      initApp(container);
      return;
    }
    if (retries <= 0) {
      log('products container not found; giving up.');
      return;
    }
    setTimeout(() => initWhenReady(retries - 1), 300);
  }

  function initApp(container) {
    try {
      const ui = createFloatingUI((query, desc) => {
        filterAndResort(container, desc, query);
      });

      // initial run
      filterAndResort(container, ui.getDesc(), ui.input.value || '');

      // observe container for dynamic changes (safe debounce)
      const mo = new MutationObserver(debounce(() => {
        try {
          // on DOM changes, re-run filter/resort with current UI state
          filterAndResort(container, ui.getDesc(), ui.input.value || '');
        } catch (e) { log('MO callback error', e); }
      }, 300));
      mo.observe(container, { childList: true, subtree: true });

      // re-run after history navigation
      window.addEventListener('popstate', () => {
        setTimeout(() => filterAndResort(container, ui.getDesc(), ui.input.value || ''), 300);
      });

      log('CamX userscript initialized');
    } catch (e) {
      log('initApp error', e);
    }
  }

  // Start
  initWhenReady();
})();