Greasy Fork Filter

Native Greasy Fork install filter sync + live keyword blocking

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Greasy Fork Filter
// @namespace    https://github.com/quantavil/userscript
// @version      1.4
// @description  Native Greasy Fork install filter sync + live keyword blocking
// @match        https://greatest.deepsurf.us/*/scripts*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  // ─── Config ───────────────────────────────────────────────────────────────
  const FIELDS = {
    daily: { label: 'Daily installs', param: 'daily_installs', data: 'scriptDailyInstalls' },
    total: { label: 'Total installs', param: 'total_installs', data: 'scriptTotalInstalls' },
  };

  const DEFAULTS = { keywords: [] };
  for (const key of Object.keys(FIELDS)) {
    DEFAULTS[key] = 0;
    DEFAULTS[`${key}Op`] = 'gt';
  }

  // ─── Helpers ──────────────────────────────────────────────────────────────
  const validOp = v => v === 'gt' || v === 'lt';
  const toNum = v => Math.max(0, parseInt(v, 10) || 0);
  const cleanKeywords = list => [...new Set(
    (Array.isArray(list) ? list : [])
      .map(v => String(v).trim().toLowerCase())
      .filter(Boolean)
  )];

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

  const el = (tag, attrs = {}, ...kids) => {
    const node = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'dataset' && v) Object.assign(node.dataset, v);
      else if (k === 'style' && typeof v === 'string') node.style.cssText = v;
      else if (k in node) node[k] = v;
      else node.setAttribute(k, v);
    }
    kids.flat().forEach(k => k != null && node.append(k));
    return node;
  };

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

  // ─── State ────────────────────────────────────────────────────────────────
  const S = { open: false };
  for (const [k, v] of Object.entries(DEFAULTS)) S[k] = GM_getValue(k, v);

  S.keywords = cleanKeywords(S.keywords);
  for (const key of Object.keys(FIELDS)) {
    S[key] = toNum(S[key]);
    S[`${key}Op`] = validOp(S[`${key}Op`]) ? S[`${key}Op`] : 'gt';
  }

  const save = (k, v) => {
    S[k] = v;
    GM_setValue(k, v);
  };

  const resetAll = () => {
    for (const [k, v] of Object.entries(DEFAULTS)) {
      save(k, Array.isArray(v) ? [] : v);
    }
  };

  const exportState = () =>
    Object.fromEntries(Object.keys(DEFAULTS).map(k => [k, S[k]]));

  // If current URL has native GF filter params, reflect them into local state.
  (() => {
    const p = new URL(location.href).searchParams;
    for (const [key, field] of Object.entries(FIELDS)) {
      const value = p.get(field.param);
      const op = p.get(`${field.param}_operator`);
      if (value !== null && value !== '') save(key, toNum(value));
      if (validOp(op)) save(`${key}Op`, op);
    }
  })();

  // ─── 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: 296px; 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: 8px;
    }
    .gf-row:last-child { margin-bottom: 0; }
    .gf-row-text { font-size: 13px; color: #3a3a3a; flex: 1; }

    .gf-control { display: flex; align-items: center; gap: 6px; }

    .gf-op-wrap { position: relative; }
    .gf-op-wrap::after {
      content: '▾';
      position: absolute; right: 9px; top: 50%;
      transform: translateY(-50%);
      pointer-events: none; color: #777; font-size: 10px;
    }

    .gf-op,
    .gf-num {
      height: 28px; 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, background .15s;
      box-sizing: border-box;
    }

    .gf-op {
      width: 58px; padding: 0 25px 0 9px;
      appearance: none; -webkit-appearance: none; -moz-appearance: none;
      cursor: pointer;
    }

    .gf-num {
      width: 78px; padding: 0 9px; text-align: right;
      -moz-appearance: textfield;
    }
    .gf-num::-webkit-inner-spin-button { -webkit-appearance: none; }

    .gf-op:focus,
    .gf-num:focus,
    .gf-kw-input:focus {
      border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07);
    }

    .gf-note {
      margin-top: 10px; font-size: 11.5px; line-height: 1.45;
      color: #8a8a8a;
    }

    .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;
      box-sizing: border-box;
    }

    .gf-actions { display: flex; gap: 6px; flex-wrap: wrap; }

    .gf-btn {
      height: 28px; padding: 0 11px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #3a3a3a;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s, border-color .15s, opacity .15s;
      white-space: nowrap;
    }
    .gf-btn:hover { background: #e5e5e7; border-color: rgba(0,0,0,0.2); }

    .gf-btn.primary {
      background: #1a1a1a; color: #f0f0f0;
      border-color: rgba(0,0,0,0.15);
    }
    .gf-btn.primary:hover { background: #333; }

    .gf-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-footer {
      padding: 9px 15px; display: flex;
      align-items: center; justify-content: space-between;
      gap: 12px;
    }
    .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;
      white-space: nowrap;
    }
    .gf-clear:hover { opacity: 1; }

    .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); } }

    @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-op-wrap::after { color: #777; }
      .gf-op,
      .gf-num,
      .gf-kw-input     { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #f0f0f2; }
      .gf-op:focus,
      .gf-num:focus,
      .gf-kw-input:focus { border-color: #666; box-shadow: 0 0 0 3px rgba(255,255,255,0.06); }
      .gf-note         { color: #757575; }
      .gf-btn          { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #bbb; }
      .gf-btn:hover    { background: #3a3a3c; }
      .gf-btn.primary  { background: #3a3a3c; border-color: rgba(255,255,255,0.08); color: #e0e0e0; }
      .gf-btn.primary:hover { background: #4a4a4c; }
      .gf-btn:disabled { background: #2c2c2e; color: #555; }
      .gf-tag          { background: #3a3a3c; color: #ddd; }
      .gf-tag-x        { background: rgba(255,255,255,0.1); }
      .gf-stat         { color: #555; }
      .gf-stat b       { color: #ccc; }
    }
  ` }));

  // ─── URL Sync ─────────────────────────────────────────────────────────────
  const buildUrl = () => {
    const url = new URL(location.href);
    const p = url.searchParams;

    p.delete('page');

    for (const [key, field] of Object.entries(FIELDS)) {
      const opKey = `${field.param}_operator`;
      if (S[key] > 0) {
        p.set(field.param, String(S[key]));
        p.set(opKey, S[`${key}Op`]);
      } else {
        p.delete(field.param);
        p.delete(opKey);
      }
    }

    return url.toString();
  };

  const isUrlSynced = () => {
    const p = new URL(location.href).searchParams;
    return Object.entries(FIELDS).every(([key, field]) => {
      const active = S[key] > 0;
      return (p.get(field.param) || '') === (active ? String(S[key]) : '') &&
             (p.get(`${field.param}_operator`) || '') === (active ? S[`${key}Op`] : '');
    });
  };

  const updateSyncBtn = () => {
    const synced = isUrlSynced();
    syncBtn.disabled = synced;
    syncBtn.textContent = synced ? 'URL synced' : 'Sync to URL';
  };

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

  const passes = (value, key) =>
    !S[key] || (S[`${key}Op`] === 'gt' ? value > S[key] : value < S[key]);

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

  const applyFilter = debounce(() => {
    shown = 0;
    hidden = 0;

    document.querySelectorAll('#browse-script-list > li[data-script-id]').forEach(li => {
      const text = `${li.dataset.scriptName || ''} ${li.querySelector('.script-description')?.textContent || ''}`.toLowerCase();
      const blocked = S.keywords.some(k => text.includes(k));
      const badNums = Object.entries(FIELDS).some(([key, field]) =>
        !passes(+li.dataset[field.data] || 0, key)
      );

      li.hidden = blocked || badNums;
      li.hidden ? hidden++ : shown++;
    });

    updateStat();
    updateSyncBtn();
  }, 120);

  // ─── UI Builders ──────────────────────────────────────────────────────────
  const makeFieldRow = ([key, field]) => {
    const op = el('select', { className: 'gf-op', dataset: { key: `${key}Op` } },
      el('option', { value: 'gt', textContent: '>' }),
      el('option', { value: 'lt', textContent: '<' }),
    );
    op.value = S[`${key}Op`];

    const input = el('input', {
      className: 'gf-num',
      type: 'number',
      min: 0,
      value: S[key],
      dataset: { key },
    });

    return el('div', { className: 'gf-row' },
      el('span', { className: 'gf-row-text', textContent: field.label }),
      el('div', { className: 'gf-control' },
        el('div', { className: 'gf-op-wrap' }, op),
        input,
      ),
    );
  };

  const renderTags = () => {
    tagsEl.textContent = '';
    S.keywords.forEach(kw => {
      tagsEl.append(
        el('span', { className: 'gf-tag' },
          kw,
          el('button', { className: 'gf-tag-x', textContent: '×', dataset: { kw } }),
        )
      );
    });
  };

  const syncUI = () => {
    panel.querySelectorAll('[data-key]').forEach(node => {
      node.value = S[node.dataset.key];
    });
    kwInput.value = '';
    addBtn.disabled = true;
    renderTags();
    updateSyncBtn();
  };

  // ─── Import / Export ──────────────────────────────────────────────────────
  const exportSettings = () => {
    const json = JSON.stringify(exportState(), 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', hidden: true });
    fi.addEventListener('change', () => {
      const file = fi.files?.[0];
      fi.remove();
      if (!file) return;

      const reader = new FileReader();
      reader.onload = () => {
        try {
          const data = JSON.parse(String(reader.result || '{}'));

          for (const key of Object.keys(FIELDS)) {
            if (key in data) save(key, toNum(data[key]));
            if (validOp(data[`${key}Op`])) save(`${key}Op`, data[`${key}Op`]);
          }
          if ('keywords' in data) save('keywords', cleanKeywords(data.keywords));

          syncUI();
          applyFilter();
          toast('Settings imported');
        } catch {
          toast('Invalid JSON file');
        }
      };
      reader.readAsText(file);
    });
    document.body.append(fi);
    fi.click();
  };

  // ─── Build UI ─────────────────────────────────────────────────────────────
  const statEl = el('span', { className: 'gf-stat' });
  const syncBtn = el('button', { className: 'gf-btn primary', textContent: 'Sync to URL' });
  const exportBtn = el('button', { className: 'gf-btn', textContent: 'Export JSON' });
  const importBtn = el('button', { className: 'gf-btn', textContent: 'Import JSON' });
  const clearBtn = el('button', { className: 'gf-clear', textContent: 'Reset all' });

  const kwInput = el('input', {
    className: 'gf-kw-input',
    type: 'text',
    placeholder: 'e.g. "mod menu", cheat…',
  });
  const addBtn = el('button', {
    className: 'gf-btn primary',
    textContent: 'Block',
    disabled: true,
  });
  const tagsEl = el('div', { className: 'gf-tags' });

  const panel = el('div', { id: 'gf-panel', className: 'closed' },
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Install threshold' }),
      ...Object.entries(FIELDS).map(makeFieldRow),
      el('div', {
        className: 'gf-note',
        textContent: 'Sync to URL reloads the page and uses Greasy Fork’s native install filters. Keyword blocking stays live and local.',
      }),
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Block keywords' }),
      el('div', { className: 'gf-kw-row' }, kwInput, addBtn),
      tagsEl,
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Settings' }),
      el('div', { className: 'gf-actions' }, syncBtn, exportBtn, importBtn),
    ),
    el('div', { className: 'gf-footer' }, statEl, clearBtn),
  );

  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);

  // ─── Events ───────────────────────────────────────────────────────────────
  const addKeyword = () => {
    const kw = kwInput.value.trim().toLowerCase();
    if (!kw) return;
    if (!S.keywords.includes(kw)) save('keywords', [...S.keywords, kw]);
    kwInput.value = '';
    addBtn.disabled = true;
    renderTags();
    applyFilter();
  };

  kwInput.addEventListener('input', () => {
    addBtn.disabled = !kwInput.value.trim();
  });

  kwInput.addEventListener('keydown', e => {
    if (e.key === 'Enter') addKeyword();
  });

  addBtn.addEventListener('click', addKeyword);

  tagsEl.addEventListener('click', e => {
    const btn = e.target.closest('button[data-kw]');
    if (!btn) return;
    save('keywords', S.keywords.filter(k => k !== btn.dataset.kw));
    renderTags();
    applyFilter();
  });

  panel.addEventListener('input', e => {
    const key = e.target.dataset.key;
    if (!key) return;
    save(key, key.endsWith('Op') ? (validOp(e.target.value) ? e.target.value : 'gt') : toNum(e.target.value));
    applyFilter();
  });

  panel.addEventListener('change', e => {
    const key = e.target.dataset.key;
    if (!key) return;
    save(key, key.endsWith('Op') ? (validOp(e.target.value) ? e.target.value : 'gt') : toNum(e.target.value));
    applyFilter();
  });

  exportBtn.addEventListener('click', exportSettings);
  importBtn.addEventListener('click', importSettings);

  syncBtn.addEventListener('click', () => {
    if (isUrlSynced()) return toast('Already synced');
    location.assign(buildUrl());
  });

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

  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 => {
    if (S.open && !panel.contains(e.target) && !fab.contains(e.target)) toggle(false);
  });

  document.addEventListener('keydown', e => {
    if (e.key === 'Escape') toggle(false);
  });

  // ─── Init ─────────────────────────────────────────────────────────────────
  syncUI();
  applyFilter();

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