GGn Filter Forum Posts

Adds a "Filter Posts" panel at the top of forum 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.

(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.

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         GGn Filter Forum Posts
// @version      1.0.0
// @author       SleepingGiant
// @namespace    https://greasyfork.org/users/1395131
// @description  Adds a "Filter Posts" panel at the top of forum pages.
// @match        https://gazellegames.net/forums.php?*action=viewthread*&threadid=*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'sg_filterposts_panel';
  const LINK_ID  = 'sg_filterposts_link';
  const STORAGE_KEY = 'sg_filterposts_state';


  // --- persistence logic ---
    function saveState() {
    const usersRaw = document.getElementById('sg_fp_users')?.value ?? '';
    const blockedTextList = getBlockedText();
    GM_setValue(STORAGE_KEY, JSON.stringify({ usersRaw, blockedTextList }));
    }

    function loadState() {
    const raw = GM_getValue(STORAGE_KEY, '');
    if (!raw) return null;
    try {
        return JSON.parse(raw);
    } catch {
        return null;
    }
    }

  function applyStateToUI(state) {
    if (!state) return;

    const u = document.getElementById('sg_fp_users');
    if (u) u.value = state.usersRaw || '';

    const block = document.getElementById('sg_fp_text_block');
    if (!block) return;

    // Ensure enough rows for all saved "contains" strings
    const want = Math.max(1, (state.blockedTextList || []).length);
    const getRows = () => Array.from(block.querySelectorAll('.sg_fp_text_row'));

    while (getRows().length < want) {
      // mimic addRow() behavior (local to initTextBlockControls)
      block.appendChild(document.createElement('br'));
      const span = document.createElement('span');
      span.className = 'sg_fp_text_row';
      span.dataset.index = String(getRows().length);
      span.innerHTML = `<input type="search" class="sg_fp_text_input" style="width: 90%;" value="">`;
      span.style.marginTop = '5px';
      block.appendChild(span);
    }

    const inputs = Array.from(document.querySelectorAll('.sg_fp_text_input'));
    inputs.forEach((inp, i) => {
      inp.value = (state.blockedTextList && state.blockedTextList[i]) ? String(state.blockedTextList[i]) : '';
    });
  }
  // --- end persistence logic ---

  function normUsername(raw) {
    // remove the ∇ indicator anywhere, normalize whitespace
    return (raw || '').replace(/∇/g, '').trim();
  }

  function tokenizeUserList(s) {
    // comma / semicolon / newline separated
    return (s || '')
      .split(/[,;\n]+/)
      .map(x => x.trim())
      .filter(Boolean);
  }

  function getThreadHeaderCenter() {
    // Area that contains [ Report Thread ] ... [ Search This Thread ]
    return document.querySelector('.linkbox.linkbox_top .center');
  }

  function buildPanel() {
    const wrap = document.createElement('div');
    wrap.id = PANEL_ID;
    wrap.className = 'hidden center';

    wrap.innerHTML = `
      <div class="sg-filterposts-inner">
        <h3 style="margin: 0;">Filter Posts:</h3>
        <table cellpadding="6" cellspacing="1" border="0" class="layout border" style="margin: 0 auto;">
          <tbody>
            <tr>
              <td><strong>Hide user(s):</strong></td>
              <td>
                <input type="search" id="sg_fp_users" placeholder="e.g. foo,bar,test" size="70">
                <div class="sg-filterposts-hint">Comma/newline separated. Case-insensitive. Ignores ∇.</div>
              </td>
            </tr>

            <tr>
              <td><strong>Hide if contains:</strong></td>
              <td id="sg_fp_text_block">
                <span class="sg_fp_text_row" data-index="0">
                  <input type="search" class="sg_fp_text_input" style="width: 90%;" value="">
                </span>
                <a href="#" id="sg_fp_text_add">+</a>
                <a href="#" id="sg_fp_text_remove">–</a>
              </td>
            </tr>
            <tr>
              <td colspan="2" style="text-align:center;">
                <button type="button" class="sg-filterposts-btn" id="sg_fp_apply">Apply</button>
                <button type="button" class="sg-filterposts-btn" id="sg_fp_showall">Show all</button>
                <button type="button" class="sg-filterposts-btn" id="sg_fp_clear">Clear fields</button>
                <span id="sg_fp_count" class="sg-filterposts-count"></span>
              </td>
            </tr>
          </tbody>
        </table>
        <br>
      </div>
    `;

    return wrap;
  }

  function getPosts() {
    return Array.from(document.querySelectorAll('table.forum_post'));
  }

  function getPostUsername(postTable) {
    const a = postTable.querySelector('tr.colhead_dark a.username');
    return normUsername(a ? a.textContent : '');
  }

  function getPostText(postTable) {
    const body = postTable.querySelector('td.body');
    return (body ? body.textContent : '').trim();
  }

  function setHiddenForPost(postTable, hidden) {
    const maybeSub = postTable.previousElementSibling;
    if (maybeSub && maybeSub.classList && maybeSub.classList.contains('sub')) {
      maybeSub.style.display = hidden ? 'none' : '';
    }
    postTable.style.display = hidden ? 'none' : '';
  }

  function showAll() {
    getPosts().forEach(p => setHiddenForPost(p, false));
    const countEl = document.getElementById('sg_fp_count');
    if (countEl) countEl.textContent = '';
  }

  function getBlockedText() {
    return Array.from(document.querySelectorAll('.sg_fp_text_input'))
      .map(i => (i.value || '').trim().toLowerCase())
      .filter(Boolean);
  }

  function applyFilter() {
    saveState();

    const usersRaw = document.getElementById('sg_fp_users')?.value ?? '';
    const blockedUsers = tokenizeUserList(usersRaw).map(u => u.toLowerCase());
    const blockedTextList = getBlockedText();

    let hiddenCount = 0;

    getPosts().forEach(post => {
      const uname = getPostUsername(post).toLowerCase();
      const text  = getPostText(post).toLowerCase();
      const hasUserCrit = blockedUsers.length > 0;
      const hasTextCrit = blockedTextList.length > 0;
      const userMatches = hasUserCrit ? blockedUsers.some(user => user && uname === user) : false;
      const textMatches = hasTextCrit ? blockedTextList.some(blockedText => text.includes(blockedText)) : false;
      const shouldHide =(hasUserCrit && userMatches) ||(hasTextCrit && textMatches);

      setHiddenForPost(post, shouldHide);
      if (shouldHide) hiddenCount++;
    });

    const countEl = document.getElementById('sg_fp_count');
    if (countEl) countEl.textContent = hiddenCount ? `Hidden: ${hiddenCount}` : '';
  }

  function initTextBlockControls(panelEl) {
    const block = panelEl.querySelector('#sg_fp_text_block');
    const addBtn = panelEl.querySelector('#sg_fp_text_add');
    const remBtn = panelEl.querySelector('#sg_fp_text_remove');

    function getRows() {
      return Array.from(block.querySelectorAll('.sg_fp_text_row'));
    }

    function addRow() {
      const rows = getRows();
      const idx = rows.length;
      block.appendChild(document.createElement('br'));

      const span = document.createElement('span');
      span.className = 'sg_fp_text_row';
      span.dataset.index = String(idx);

      span.innerHTML = `<input type="search" class="sg_fp_text_input" style="width: 90%;" value="">`;
      span.style.marginTop = '5px';
      block.appendChild(span);
    }

    function removeRow() {
      const rows = getRows();
      if (rows.length <= 1) return;

      const last = rows[rows.length - 1];
      const input = last.querySelector('input');

      // Must be empty to remove
      if (input && input.value.trim() === '') {
        // remove the preceding <br> if present
        const prev = last.previousSibling;
        if (prev && prev.nodeName === 'BR') prev.remove();
        last.remove();
      }
    }

    addBtn.addEventListener('click', (e) => {
      e.preventDefault();
      addRow();
    });

    remBtn.addEventListener('click', (e) => {
      e.preventDefault();
      removeRow();
    });

    // Enter in any "contains" field applies
    block.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        const target = e.target;
        if (target && target.classList && target.classList.contains('sg_fp_text_input')) {
          e.preventDefault();
          applyFilter();
        }
      }
    });
  }

  function wirePanelButtons(panelEl) {
    panelEl.querySelector('#sg_fp_apply')?.addEventListener('click', applyFilter);
    panelEl.querySelector('#sg_fp_showall')?.addEventListener('click', showAll);

    panelEl.querySelector('#sg_fp_clear')?.addEventListener('click', () => {
      const u = document.getElementById('sg_fp_users');
      if (u) u.value = '';

      // Clear all "contains" inputs and reduce back to a single row
      const inputs = Array.from(document.querySelectorAll('.sg_fp_text_input'));
      inputs.forEach(i => { i.value = ''; });

      const block = document.getElementById('sg_fp_text_block');
      if (block) {
        const rows = Array.from(block.querySelectorAll('.sg_fp_text_row'));
        for (let i = rows.length - 1; i >= 1; i--) {
          const row = rows[i];
          const prev = row.previousSibling;
          if (prev && prev.nodeName === 'BR') prev.remove();
          row.remove();
        }
      }
    });

    // Enter in username field applies
    panelEl.querySelector('#sg_fp_users')?.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        applyFilter();
      }
    });
  }

  function addHeaderLinkAndPanel() {
    const center = getThreadHeaderCenter();
    if (!center) return;
    if (document.getElementById(LINK_ID) || document.getElementById(PANEL_ID)) return;

    const filterLink = document.createElement('a');
    filterLink.id = LINK_ID;
    filterLink.href = '#';
    filterLink.textContent = '[ Filter Posts ]';

    const panel = buildPanel();
    wirePanelButtons(panel);
    initTextBlockControls(panel);

    filterLink.addEventListener('click', (e) => {
      e.preventDefault();
      panel.classList.toggle('hidden');
    });

    center.appendChild(document.createTextNode(''));
    center.appendChild(filterLink);

    // Insert the panel right after #searchthread if it exists
    const searchThreadDiv = document.getElementById('searchthread');
    if (searchThreadDiv && searchThreadDiv.parentNode) {
      searchThreadDiv.parentNode.insertBefore(panel, searchThreadDiv.nextSibling);
    } else {
      const linkboxTop = document.querySelector('.linkbox.linkbox_top');
      if (linkboxTop) linkboxTop.appendChild(panel);
      else document.body.insertBefore(panel, document.body.firstChild);
    }

    // Attempt to load previous filters.
    const state = loadState();
    if (state) {
      applyStateToUI(state);
      applyFilter();
    }
  }

  GM_addStyle(`
    #${PANEL_ID}.hidden { display: none; }
    #${PANEL_ID} { margin-top: 8px; }
    .sg-filterposts-inner { display: inline-block; }
    .sg-filterposts-btn {
      padding: 2px 8px;
      margin: 0 4px;
      cursor: pointer;
    }
    .sg-filterposts-hint {
      margin-top: 4px;
      font-size: 11px;
      opacity: 0.8;
    }
    .sg-filterposts-count {
      margin-left: 10px;
      font-weight: bold;
    }
    #sg_fp_text_block br { line-height: 10px; }
  `);

  window.addEventListener('load', addHeaderLinkAndPanel);
})();