Greasy Fork Filter

Filter scripts by installs and keywords — with import/export

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.

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

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Greasy Fork Filter
// @namespace    gf-filter
// @version      1.1
// @description  Filter scripts by installs and keywords — with import/export
// @match        https://greasyfork.org/*/scripts*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ─── State ────────────────────────────────────────────────────────────────
  const DEFAULTS = { daily: 0, total: 0, keywords: [] };

  const S = {
    daily:    GM_getValue('daily',    DEFAULTS.daily),
    total:    GM_getValue('total',    DEFAULTS.total),
    keywords: GM_getValue('keywords', DEFAULTS.keywords),
    open:     false,
  };

  const save = (k, v) => { S[k] = v; GM_setValue(k, v); };
  const resetAll = () => Object.entries(DEFAULTS).forEach(([k, v]) => save(k, v));

  // ─── Helpers ──────────────────────────────────────────────────────────────
  const debounce = (fn, ms) => {
    let t;
    return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
  };

  const el = (tag, attrs = {}, ...kids) => {
    const n = Object.assign(document.createElement(tag), attrs);
    kids.forEach(c => n.append(c));
    return n;
  };

  const toast = msg => {
    const t = el('div', { className: 'gf-toast', textContent: msg });
    document.body.append(t);
    setTimeout(() => t.remove(), 2000);
  };

  // ─── CSS ──────────────────────────────────────────────────────────────────
  document.head.append(el('style', { textContent: `
    #gf-fab {
      position: fixed; bottom: 28px; right: 28px; z-index: 99999;
      width: 40px; height: 40px; border-radius: 10px;
      background: #1a1a1a; border: 1px solid rgba(255,255,255,0.08);
      cursor: pointer; display: grid; place-items: center;
      box-shadow: 0 2px 12px rgba(0,0,0,0.3);
      transition: transform .18s, background .18s;
    }
    #gf-fab:hover { transform: scale(1.06); background: #2a2a2a; }
    #gf-fab.active { background: #111; }
    #gf-fab svg { color: #e0e0e0; display: block; pointer-events: none; }

    #gf-panel {
      position: fixed; bottom: 78px; right: 28px; z-index: 99999;
      width: 276px; background: #fff;
      border: 1px solid rgba(0,0,0,0.09); border-radius: 14px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.13), 0 2px 8px rgba(0,0,0,0.05);
      font: 13px/1.45 -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
      color: #1a1a1a; transform-origin: bottom right;
      transition: transform .22s cubic-bezier(.34,1.56,.64,1), opacity .16s;
    }
    #gf-panel.closed {
      transform: scale(0.86) translateY(10px);
      opacity: 0; pointer-events: none;
    }

    .gf-section { padding: 13px 15px; border-bottom: 1px solid rgba(0,0,0,0.06); }
    .gf-section:last-child { border-bottom: none; }

    .gf-label {
      font-size: 10.5px; font-weight: 600;
      letter-spacing: .06em; text-transform: uppercase;
      color: #999; margin-bottom: 10px;
    }

    .gf-row {
      display: flex; align-items: center;
      justify-content: space-between; gap: 10px; margin-bottom: 7px;
    }
    .gf-row:last-child { margin-bottom: 0; }
    .gf-row-text { font-size: 13px; color: #3a3a3a; flex: 1; }

    .gf-num {
      width: 70px; height: 28px; padding: 0 9px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #1a1a1a;
      font-size: 13px; text-align: right; outline: none;
      transition: border-color .15s, box-shadow .15s;
      -moz-appearance: textfield;
    }
    .gf-num::-webkit-inner-spin-button { -webkit-appearance: none; }
    .gf-num:focus { border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07); }

    .gf-kw-row { display: flex; gap: 6px; margin-bottom: 9px; }
    .gf-kw-input {
      flex: 1; height: 28px; padding: 0 9px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #1a1a1a;
      font-size: 13px; outline: none;
      transition: border-color .15s, box-shadow .15s;
    }
    .gf-kw-input:focus { border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07); }

    .gf-add-btn {
      height: 28px; padding: 0 11px;
      border: 1px solid rgba(0,0,0,0.15); border-radius: 7px;
      background: #1a1a1a; color: #f0f0f0;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s; white-space: nowrap;
    }
    .gf-add-btn:hover { background: #333; }
    .gf-add-btn:disabled {
      background: #e5e5e7; color: #bbb;
      border-color: transparent; cursor: default;
    }

    .gf-tags { display: flex; flex-wrap: wrap; gap: 5px; min-height: 4px; }
    .gf-tag {
      display: inline-flex; align-items: center; gap: 4px;
      padding: 3px 6px 3px 9px; background: #ebebed; color: #2a2a2a;
      border-radius: 20px; font-size: 11.5px; font-weight: 500;
      animation: gf-pop .14s ease;
    }
    @keyframes gf-pop {
      from { transform: scale(0.72); opacity: 0; }
      to   { transform: scale(1); opacity: 1; }
    }

    .gf-tag-x {
      display: grid; place-items: center;
      width: 15px; height: 15px; border-radius: 50%;
      background: rgba(0,0,0,0.1); border: none;
      cursor: pointer; color: inherit;
      font-size: 11px; line-height: 1; padding: 0;
      transition: background .12s;
    }
    .gf-tag-x:hover { background: #e63946; color: #fff; }

    .gf-io-row { display: flex; gap: 6px; margin-top: 11px; }
    .gf-io-btn {
      flex: 1; height: 28px; border-radius: 7px;
      border: 1px solid rgba(0,0,0,0.13);
      background: #f5f5f7; color: #3a3a3a;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s, border-color .15s;
    }
    .gf-io-btn:hover { background: #e5e5e7; border-color: rgba(0,0,0,0.2); }

    .gf-toast {
      position: fixed; bottom: 78px; right: 28px; z-index: 100000;
      padding: 8px 14px; border-radius: 8px;
      background: #1a1a1a; color: #f0f0f0;
      font: 12px/1 -apple-system, system-ui, sans-serif;
      pointer-events: none;
      animation: gf-toast-in .2s ease, gf-toast-out .25s ease 1.6s forwards;
    }
    @keyframes gf-toast-in  { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
    @keyframes gf-toast-out { to   { opacity:0; transform:translateY(4px); } }

    .gf-footer {
      padding: 9px 15px; display: flex;
      align-items: center; justify-content: space-between;
    }
    .gf-stat { font-size: 12px; color: #999; }
    .gf-stat b { color: #1a1a1a; font-weight: 600; }

    .gf-clear {
      font-size: 12px; color: #e63946; background: none;
      border: none; cursor: pointer; padding: 0;
      opacity: .75; transition: opacity .12s;
    }
    .gf-clear:hover { opacity: 1; }

    @media (prefers-color-scheme: dark) {
      #gf-panel {
        background: #1c1c1e; border-color: rgba(255,255,255,0.09);
        color: #f0f0f2; box-shadow: 0 12px 40px rgba(0,0,0,0.5);
      }
      .gf-section   { border-bottom-color: rgba(255,255,255,0.06); }
      .gf-label      { color: #555; }
      .gf-row-text   { color: #bbb; }
      .gf-num,
      .gf-kw-input   { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #f0f0f2; }
      .gf-num:focus,
      .gf-kw-input:focus { border-color: #666; box-shadow: 0 0 0 3px rgba(255,255,255,0.06); }
      .gf-add-btn    { background: #3a3a3c; border-color: rgba(255,255,255,0.08); color: #e0e0e0; }
      .gf-add-btn:hover    { background: #4a4a4c; }
      .gf-add-btn:disabled { background: #2c2c2e; color: #555; }
      .gf-tag        { background: #3a3a3c; color: #ddd; }
      .gf-tag-x      { background: rgba(255,255,255,0.1); }
      .gf-io-btn     { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #bbb; }
      .gf-io-btn:hover { background: #3a3a3c; }
      .gf-stat       { color: #555; }
      .gf-stat b     { color: #ccc; }
    }
  ` }));

  // ─── Filter ───────────────────────────────────────────────────────────────
  let shown = 0, hidden = 0;

  const applyFilter = debounce(() => {
    shown = 0; hidden = 0;
    const kws = S.keywords.map(k => k.toLowerCase());

    document.querySelectorAll('#browse-script-list > li[data-script-id]').forEach(li => {
      const daily    = +li.dataset.scriptDailyInstalls || 0;
      const total    = +li.dataset.scriptTotalInstalls || 0;
      const name     = (li.dataset.scriptName || '').toLowerCase();
      const desc     = (li.querySelector('.script-description')?.textContent || '').toLowerCase();
      const haystack = name + ' ' + desc;

      const filtered = daily < S.daily
                    || total < S.total
                    || kws.some(k => haystack.includes(k));

      li.style.display = filtered ? 'none' : '';
      filtered ? hidden++ : shown++;
    });

    updateStat();
  }, 120);

  // ─── Tag‑list builder ────────────────────────────────────────────────────
  const makeTagSection = (listKey, placeholder) => {
    const tagsEl = el('div', { className: 'gf-tags' });
    const input  = el('input', { className: 'gf-kw-input', type: 'text', placeholder });
    const addBtn = el('button', { className: 'gf-add-btn', textContent: 'Block', disabled: true });

    const render = () => {
      tagsEl.innerHTML = '';
      S[listKey].forEach(val => {
        const x = el('button', { className: 'gf-tag-x', textContent: '×' });
        x.addEventListener('click', e => {
          e.stopPropagation();              // prevent panel‑close handler
          save(listKey, S[listKey].filter(v => v !== val));
          render();
          applyFilter();
        });
        tagsEl.append(el('span', { className: 'gf-tag' }, val, x));
      });
    };

    const add = () => {
      const phrase = input.value.trim().toLowerCase();
      if (!phrase || S[listKey].includes(phrase)) {
        input.value = ''; addBtn.disabled = true; return;
      }
      save(listKey, [...S[listKey], phrase]);
      input.value = ''; addBtn.disabled = true;
      render();
      applyFilter();
    };

    input.addEventListener('input',   () => { addBtn.disabled = !input.value.trim(); });
    input.addEventListener('keydown', e => e.key === 'Enter' && add());
    addBtn.addEventListener('click',  add);

    return {
      tagsEl, input, addBtn, render,
      row: el('div', { className: 'gf-kw-row' }, input, addBtn),
    };
  };

  // ─── Import / Export ──────────────────────────────────────────────────────
  const exportSettings = () => {
    const json = JSON.stringify(
      { daily: S.daily, total: S.total, keywords: S.keywords }, null, 2
    );
    const a = el('a', {
      href:     'data:application/json;charset=utf-8,' + encodeURIComponent(json),
      download: 'gf-filter-settings.json',
    });
    document.body.append(a); a.click(); a.remove();
    toast('Settings exported');
  };

  const importSettings = () => {
    const fi = el('input', { type: 'file', accept: '.json', style: 'display:none' });
    fi.addEventListener('change', () => {
      const file = fi.files[0]; fi.remove();
      if (!file) return;
      const reader = new FileReader();
      reader.onload = e => {
        try {
          const d = JSON.parse(e.target.result);
          if (typeof d !== 'object' || d === null) throw 0;
          if (typeof d.daily === 'number')  save('daily', d.daily);
          if (typeof d.total === 'number')  save('total', d.total);
          if (Array.isArray(d.keywords))    save('keywords', d.keywords.map(k => String(k).toLowerCase()));
          syncUI();
          applyFilter();
          toast('Settings imported');
        } catch { toast('Invalid JSON file'); }
      };
      reader.readAsText(file);
    });
    document.body.append(fi); fi.click();
  };

  // ─── Build UI ─────────────────────────────────────────────────────────────
  const kwSection = makeTagSection('keywords', 'e.g. "mod menu", cheat…');
  const statEl    = el('span', { className: 'gf-stat' });
  const clearBtn  = el('button', { className: 'gf-clear', textContent: 'Reset all' });

  const updateStat = () => {
    statEl.innerHTML = `<b>${shown}</b> shown &nbsp;·&nbsp; <b>${hidden}</b> hidden`;
  };

  const makeNumRow = (label, key) => {
    const input = el('input', {
      className: 'gf-num', type: 'number', min: 0, value: S[key],
    });
    input.dataset.key = key;
    input.addEventListener('input', () => { save(key, +input.value || 0); applyFilter(); });
    return el('div', { className: 'gf-row' },
      el('span', { className: 'gf-row-text', textContent: label }), input
    );
  };

  const panel = el('div', { id: 'gf-panel', className: 'closed' },
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Install threshold' }),
      makeNumRow('Daily installs ≥', 'daily'),
      makeNumRow('Total installs ≥', 'total'),
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Block keywords' }),
      kwSection.row,
      kwSection.tagsEl,
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Settings' }),
      el('div', { className: 'gf-io-row' },
        Object.assign(el('button', { className: 'gf-io-btn', textContent: 'Export JSON' }),
          { onclick: exportSettings }),
        Object.assign(el('button', { className: 'gf-io-btn', textContent: 'Import JSON' }),
          { onclick: importSettings }),
      ),
    ),
    el('div', { className: 'gf-footer' }, statEl, clearBtn),
  );

  // syncUI — defined after panel so querySelectorAll works at call‑time
  const syncUI = () => {
    panel.querySelectorAll('.gf-num').forEach(i => {
      const k = i.dataset.key;
      if (k) i.value = S[k];
    });
    kwSection.input.value = '';
    kwSection.addBtn.disabled = true;
    kwSection.render();
  };

  clearBtn.addEventListener('click', () => { resetAll(); syncUI(); applyFilter(); });

  const fab = el('button', { id: 'gf-fab', title: 'Filter scripts', innerHTML:
    `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
          stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
       <path d="M2 4h12"/><path d="M4.5 8h7"/><path d="M7 12h2"/>
     </svg>` });

  document.body.append(fab, panel);

  // ─── Toggle ───────────────────────────────────────────────────────────────
  const toggle = force => {
    S.open = force ?? !S.open;
    panel.classList.toggle('closed', !S.open);
    fab.classList.toggle('active', S.open);
  };

  fab.addEventListener('click', e => { e.stopPropagation(); toggle(); });
  document.addEventListener('click',   e => S.open && !panel.contains(e.target) && toggle(false));
  document.addEventListener('keydown', e => e.key === 'Escape' && toggle(false));

  // ─── Init ─────────────────────────────────────────────────────────────────
  kwSection.render();
  applyFilter();

  const list = document.getElementById('browse-script-list');
  if (list) new MutationObserver(applyFilter).observe(list, { childList: true });
})();