mataamad / MobyGames Platform List Enhancer (Genres + Score + Year filters, Hide, Infinite Scroll)

// ==UserScript==
// @name         MobyGames Platform List Enhancer (Genres + Score + Year filters, Hide, Infinite Scroll)
// @namespace    mg-enhancer
// @version      1.4.0
// @description  Hide by GENRE terms (Genres column only), require minimum score, include only rows within a year range, per-row hide/unhide with persistence, and infinite-scroll the platform list.
// @match        https://www.mobygames.com/platform/*/
// @match        https://www.mobygames.com/platform/*/page:*/
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Utilities
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const on = (el, ev, fn, opts) => el && el.addEventListener(ev, fn, opts);
  const debounce = (fn, ms = 300) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
  const throttle = (fn, ms = 200) => { let last = 0; return (...a) => { const n = Date.now(); if (n - last >= ms) { last = n; fn(...a); } }; };

  // ---------- Page anchors
  const table = $('main table');
  if (!table) return;
  table.classList.add('mg-themed-table');

  const theadRow = $('thead tr', table);
  const tbody = $('tbody', table);
  if (!theadRow || !tbody) return;

  const overflowWrap = table.closest('.overflow-x-scroll') || table.parentElement;
  const main = $('#main') || document.body;

  // ---------- Platform/page parsing
  const path = location.pathname.replace(/\/+$/, '') + '/';
  const platformMatch = path.match(/\/platform\/([^/]+)\//);
  const platformSlug = platformMatch ? platformMatch[1] : 'unknown';
  const pageMatch = path.match(/\/page:(\d+)\//);
  let currentPage = pageMatch ? parseInt(pageMatch[1], 10) : 0;
  let nextPage = currentPage === 0 ? 1 : currentPage + 1;
  let loading = false;
  let noMore = false;
  const LOAD_COOLDOWN_MS = 2000; // cooldown between page loads
  let lastLoadTs = 0;
  const basePlatformPath = `/platform/${platformSlug}/`;

  // ---------- Storage keys
  const LS_FILTER_GENRES = (slug) => `mgFilterTerms:${slug}`;       // per-platform: CSV genre terms to HIDE
  const LS_MIN_SCORE     = (slug) => `mgScoreMin:${slug}`;          // per-platform: minimum score (inclusive)
  const LS_YEAR_MIN      = (slug) => `mgYearMin:${slug}`;           // per-platform: min year (inclusive)
  const LS_YEAR_MAX      = (slug) => `mgYearMax:${slug}`;           // per-platform: max year (inclusive)
  const LS_SHOW_HIDDEN   = 'mgShowHidden';                           // global
  const LS_HIDDEN_SET    = (slug) => `mgHiddenGames:${slug}`;        // per-platform set

  // ---------- State (defaults: genres "adventure, sports", score >= 0, year 1970–2030)
  const savedGenresStr = localStorage.getItem(LS_FILTER_GENRES(platformSlug)) ?? 'adventure, sports';
  let genreTerms = parseTerms(savedGenresStr);

  let minScore = parseFloat(localStorage.getItem(LS_MIN_SCORE(platformSlug)) ?? '0');
  if (Number.isNaN(minScore)) minScore = 0;

  let yearMin = parseInt(localStorage.getItem(LS_YEAR_MIN(platformSlug) ) ?? '1970', 10);
  let yearMax = parseInt(localStorage.getItem(LS_YEAR_MAX(platformSlug) ) ?? '2030', 10);
  if (Number.isNaN(yearMin)) yearMin = null;
  if (Number.isNaN(yearMax)) yearMax = null;

  let showHidden = (localStorage.getItem(LS_SHOW_HIDDEN) ?? 'false') === 'true';
  let hiddenSet = new Set(loadHiddenArray(platformSlug));

  // ---------- Column indices (before we append "Actions")
  const genresColIdx = findColIndex('genres', 2);      // fallback to 2 (0-based)
  const scoreColIdx  = findColIndex('moby score', 3);  // fallback to 3 (0-based)
  const yearColIdx   = findColIndex('released', 1);    // fallback to 1 (0-based)

  // ---------- Styles
  injectStyles(`
    :root{
      --mg-border: var(--border-color-dark, #3a3a3f);
      --mg-input-bg: rgba(255,255,255,.06);
      --mg-input-bg-focus: rgba(255,255,255,.09);
      --mg-btn-bg: rgba(255,255,255,.08);
      --mg-btn-bg-hover: rgba(255,255,255,.12);
      --mg-highlight: rgba(120,160,255,.35);
      --mg-th-bg: rgba(255,255,255,.05);
      --mg-hover-bg: rgba(255,255,255,.04);
    }
    .mg-controls{
      display:grid; grid-template-columns: auto auto auto auto auto 1fr;
      align-items:center; gap:.75rem; margin:.75rem 0 1rem 0;
    }
    .mg-field{ display:flex; flex-direction:column; gap:.35rem; }
    .mg-field label{ font-size:.9em; opacity:.8; }
    .mg-help{ font-size:.8em; opacity:.6; grid-column: 1 / span 3; }

    .mg-input{
      min-width:240px; padding:.5rem .65rem; border:1px solid var(--mg-border);
      border-radius:8px; background:var(--mg-input-bg); color:inherit;
      transition: background .15s ease, border-color .15s ease, box-shadow .15s ease;
    }
    .mg-input:focus{
      background:var(--mg-input-bg-focus); border-color:var(--mg-highlight);
      box-shadow:0 0 0 2px var(--mg-highlight); outline:none;
    }
    .mg-input-number{ width:110px; }

    .mg-btn{
      padding:.45rem .7rem; border:1px solid var(--mg-border);
      border-radius:8px; background:var(--mg-btn-bg); color:inherit;
      cursor:pointer; transition: background .15s ease, transform .02s;
    }
    .mg-btn:hover{ background:var(--mg-btn-bg-hover); }
    .mg-btn:active{ transform:translateY(1px); }

    .mg-status{ opacity:.75; font-size:.9em; justify-self:end; }

    .mg-themed-table{ border-collapse:separate; border-spacing:0; width:100%; }
    .mg-themed-table thead tr{ background:var(--mg-th-bg); }
    .mg-themed-table th, .mg-themed-table td{ border-top:1px solid var(--mg-border); }
    .mg-themed-table tbody tr:hover{ background:var(--mg-hover-bg); }

    .mg-actions-cell{ white-space:nowrap; }

    /* Hide types */
    .mg-hidden-user{ display:none !important; }
    .mg-hidden-filter-display{ display:none !important; }

    /* 2px placeholders for filtered rows */
    tr.mg-filter-placeholder{ height:2px; }
    tr.mg-filter-placeholder > td{
      padding:0 !important; border:0 !important; height:2px !important; line-height:0 !important; font-size:0 !important;
      background:var(--mg-border);
    }

    .mg-bottom-loader{ text-align:center; margin:.5rem 0 .25rem; opacity:.8; display:none; }
  `);

  // ---------- Controls UI
  const controls = document.createElement('div');
  controls.className = 'mg-controls';

  // Genres field
  const fGenres = document.createElement('div');
  fGenres.className = 'mg-field';
  const lblGenres = document.createElement('label');
  lblGenres.textContent = 'Genres to hide (CSV)';
  const inpGenres = document.createElement('input');
  inpGenres.type = 'text';
  inpGenres.className = 'mg-input';
  inpGenres.placeholder = 'adventure, sports';
  inpGenres.value = savedGenresStr;
  fGenres.append(lblGenres, inpGenres);

  // Score min
  const fScore = document.createElement('div');
  fScore.className = 'mg-field';
  const lblScore = document.createElement('label');
  lblScore.textContent = 'Minimum score';
  const inpScore = document.createElement('input');
  inpScore.type = 'number';
  inpScore.className = 'mg-input mg-input-number';
  inpScore.min = '0'; inpScore.max = '10'; inpScore.step = '0.1';
  inpScore.value = String(minScore);
  fScore.append(lblScore, inpScore);

  // Year min
  const fYearMin = document.createElement('div');
  fYearMin.className = 'mg-field';
  const lblYearMin = document.createElement('label');
  lblYearMin.textContent = 'Year from';
  const inpYearMin = document.createElement('input');
  inpYearMin.type = 'number';
  inpYearMin.className = 'mg-input mg-input-number';
  inpYearMin.placeholder = 'min';
  inpYearMin.value = (yearMin ?? '').toString();
  fYearMin.append(lblYearMin, inpYearMin);

  // Year max
  const fYearMax = document.createElement('div');
  fYearMax.className = 'mg-field';
  const lblYearMax = document.createElement('label');
  lblYearMax.textContent = 'Year to';
  const inpYearMax = document.createElement('input');
  inpYearMax.type = 'number';
  inpYearMax.className = 'mg-input mg-input-number';
  inpYearMax.placeholder = 'max';
  inpYearMax.value = (yearMax ?? '').toString();
  fYearMax.append(lblYearMax, inpYearMax);

  // Save + show hidden
  const saveBtn = document.createElement('button');
  saveBtn.className = 'mg-btn';
  saveBtn.textContent = 'Save filters';

  const showHiddenLbl = document.createElement('label');
  showHiddenLbl.style.alignSelf = 'center';
  showHiddenLbl.style.display = 'inline-flex';
  showHiddenLbl.style.gap = '.5rem';
  showHiddenLbl.style.alignItems = 'center';
  const showHiddenCb = document.createElement('input');
  showHiddenCb.type = 'checkbox';
  showHiddenCb.checked = showHidden;
  const showHiddenSpan = document.createElement('span');
  showHiddenSpan.textContent = 'Show hidden games';
  showHiddenLbl.append(showHiddenCb, showHiddenSpan);

  // Help + status
  const help = document.createElement('div');
  help.className = 'mg-help';
  help.textContent = 'Genre filter checks only the Genres column. Score filter keeps rows with score ≥ minimum. Year filter keeps rows with Release year between “from” and “to” (inclusive). If either year is empty, year filtering is disabled. Saved per platform.';

  const status = document.createElement('span');
  status.className = 'mg-status';

  controls.append(fGenres, fScore, fYearMin, fYearMax, saveBtn, showHiddenLbl, help, status);
  overflowWrap.parentElement.insertBefore(controls, overflowWrap);

  // Bottom loader
  const bottomLoader = document.createElement('div');
  bottomLoader.className = 'mg-bottom-loader';
  bottomLoader.textContent = 'Loading…';
  overflowWrap.parentElement.insertBefore(bottomLoader, overflowWrap.nextSibling);

  // ---------- Actions column header
  const th = document.createElement('th');
  th.textContent = 'Actions';
  th.className = 'text-nowrap';
  theadRow.appendChild(th);

  // ---------- Wire control events
  const persistAndApply = () => {
    const rawGenres = (inpGenres.value || '').trim();
    const rawScore  = (inpScore.value || '').trim();
    const rawYMin   = (inpYearMin.value || '').trim();
    const rawYMax   = (inpYearMax.value || '').trim();

    localStorage.setItem(LS_FILTER_GENRES(platformSlug), rawGenres);
    localStorage.setItem(LS_MIN_SCORE(platformSlug), rawScore === '' ? '0' : rawScore);
    if (rawYMin === '') localStorage.removeItem(LS_YEAR_MIN(platformSlug));
    else localStorage.setItem(LS_YEAR_MIN(platformSlug), rawYMin);
    if (rawYMax === '') localStorage.removeItem(LS_YEAR_MAX(platformSlug));
    else localStorage.setItem(LS_YEAR_MAX(platformSlug), rawYMax);

    genreTerms = parseTerms(rawGenres);
    minScore = parseFloat(rawScore || '0') || 0;
    yearMin = rawYMin === '' ? null : parseInt(rawYMin, 10);
    yearMax = rawYMax === '' ? null : parseInt(rawYMax, 10);

    applyAllVisibility();
    flashStatus('Filters saved.');
  };

  on(saveBtn, 'click', persistAndApply);

  // Also live-apply on typing (debounced) to feel responsive
  const debouncedLiveApply = debounce(persistAndApply, 300);
  on(inpGenres, 'input', debouncedLiveApply);
  on(inpScore,  'input', debouncedLiveApply);
  on(inpYearMin,'input', debouncedLiveApply);
  on(inpYearMax,'input', debouncedLiveApply);

  on(showHiddenCb, 'change', () => {
    showHidden = !!showHiddenCb.checked;
    localStorage.setItem(LS_SHOW_HIDDEN, String(showHidden));
    applyAllVisibility();
  });

  // ---------- Decorate current rows, then apply visibility
  $$('tbody tr', table).forEach(decorateRow);
  applyAllVisibility();

  // ---------- Infinite scroll + cooldown
  const sentinel = document.createElement('div');
  sentinel.className = 'mg-sentinel';
  main.appendChild(sentinel);

  const canLoadMore = () =>
    !loading && !noMore && (Date.now() - lastLoadTs >= LOAD_COOLDOWN_MS);

  const tryLoadMore = async () => {
    if (!canLoadMore()) return;
    loading = true;
    showBottomLoader(true);
    try {
      const url = basePlatformPath + `page:${nextPage}/`;
      const doc = await fetchHTML(url);
      if (!doc) { noMore = true; return; }

      const newRows = $$('tbody tr', doc);
      if (!newRows.length) { noMore = true; return; }

      for (const tr of newRows) {
        tbody.appendChild(tr);
        decorateRow(tr);
      }
      applyAllVisibility();

      currentPage = nextPage;
      nextPage += 1;
    } catch (e) {
      console.error('[MG Enhancer] load error', e);
    } finally {
      loading = false;
      lastLoadTs = Date.now();
      showBottomLoader(false);
    }
  };

  const onScroll = throttle(() => {
    if ((window.scrollY + window.innerHeight) >= (document.body.offsetHeight - 1200)) {
      tryLoadMore();
    }
  }, 250);
  on(window, 'scroll', onScroll);

  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver((entries) => {
      if (entries.some(e => e.isIntersecting)) tryLoadMore();
    }, { rootMargin: '1200px 0px' });
    io.observe(sentinel);
  }

  // ---------- Core functions

  function decorateRow(tr) {
    if (tr.querySelector('.mg-actions-cell')) return;

    const actionsTd = document.createElement('td');
    actionsTd.className = 'mg-actions-cell';

    const btn = document.createElement('button');
    btn.className = 'mg-btn';

    const id = extractGameId(tr) || extractHref(tr) || `row-${Math.random().toString(36).slice(2)}`;
    tr.dataset.mgId = id;

    const updateBtn = () => {
      const isHidden = hiddenSet.has(id);
      btn.textContent = isHidden ? 'Unhide' : 'Hide';
      const badge = tr.querySelector('.mg-row-hidden-badge');
      if (isHidden && showHidden && !badge) {
        const b = document.createElement('span');
        b.className = 'mg-row-hidden-badge';
        b.style.cssText = 'opacity:.6;font-size:.85em;margin-left:.4rem;';
        b.textContent = '(hidden)';
        actionsTd.appendChild(b);
      } else if ((!isHidden || !showHidden) && badge) {
        badge.remove();
      }
    };

    on(btn, 'click', () => {
      if (hiddenSet.has(id)) hiddenSet.delete(id);
      else hiddenSet.add(id);
      saveHiddenArray(platformSlug, Array.from(hiddenSet));
      updateBtn();
      applyRowVisibility(tr);
      updateCounts();
    });

    actionsTd.appendChild(btn);
    tr.appendChild(actionsTd);
    updateBtn();
  }

  function applyAllVisibility() {
    $$('tbody tr', table)
      .filter(tr => !tr.classList.contains('mg-filter-placeholder'))
      .forEach(applyRowVisibility);
    updateCounts();
  }

  function applyRowVisibility(tr) {
    const id = tr.dataset.mgId || extractGameId(tr) || extractHref(tr);

    // Pull column texts & numbers
    const tds = tr.children;
    const genresText = (tds[genresColIdx]?.innerText || '').toLowerCase();
    const scoreText  = (tds[scoreColIdx]?.innerText || '').replace(/[^\d.]/g, '');
    const yearText   = (tds[yearColIdx]?.innerText || '').trim();
    const scoreVal = parseFloat(scoreText);
    const yearVal  = parseInt(yearText, 10);

    // Genre filter: hide if ANY genreTerm is contained in Genres column
    const hasGenreTerms = genreTerms.length > 0;
    const genreHit = hasGenreTerms && genreTerms.some(term => genresText.includes(term));

    // Score filter: keep only rows >= minScore
    const scoreFail = Number.isFinite(scoreVal) ? (scoreVal < minScore) : false;

    // Year filter: only apply if both bounds present; keep only in [yearMin, yearMax]
    let yearFail = false;
    if (yearMin != null && yearMax != null && Number.isFinite(yearVal)) {
      yearFail = (yearVal < yearMin || yearVal > yearMax);
    } else if ((yearMin == null) !== (yearMax == null)) {
      // If either is missing → disable year filtering
      yearFail = false;
    }

    // Should this row be filtered (hidden with 2px placeholder)?
    const shouldFilter = genreHit || scoreFail || yearFail;

    if (shouldFilter) {
      tr.classList.add('mg-hidden-filter-display');
      ensurePlaceholderAfter(tr, id);
    } else {
      tr.classList.remove('mg-hidden-filter-display');
      removePlaceholderAfter(tr, id);
    }

    // USER-HIDDEN: hide unless "show hidden games" is on
    const isUserHidden = id ? hiddenSet.has(id) : false;
    const hideForUser = isUserHidden && !showHidden;
    tr.classList.toggle('mg-hidden-user', hideForUser);
  }

  function ensurePlaceholderAfter(tr, id) {
    let sib = tr.nextElementSibling;
    if (sib && sib.classList.contains('mg-filter-placeholder') && sib.dataset.forId === id) return;
    const ph = document.createElement('tr');
    ph.className = 'mg-filter-placeholder';
    ph.dataset.forId = id;
    const td = document.createElement('td');
    td.colSpan = theadRow.children.length;
    ph.appendChild(td);
    tr.parentNode.insertBefore(ph, tr.nextSibling);
  }

  function removePlaceholderAfter(tr, id) {
    const sib = tr.nextElementSibling;
    if (sib && sib.classList.contains('mg-filter-placeholder') && sib.dataset.forId === id) {
      sib.remove();
    }
  }

  function parseTerms(s) {
    return (s || '')
      .split(',')
      .map(t => t.trim().toLowerCase())
      .filter(Boolean);
  }

  function extractGameId(tr) {
    const a = tr.querySelector('a[href*="/game/"]');
    if (!a) return null;
    const m = a.href.match(/\/game\/(\d+)\//);
    return m ? m[1] : null;
  }

  function extractHref(tr) {
    const a = tr.querySelector('a[href]');
    return a ? a.getAttribute('href') : null;
  }

  function findColIndex(needle, fallbackIdx) {
    const ths = Array.from(theadRow.children);
    const idx = ths.findIndex(th => th.textContent.trim().toLowerCase().includes(needle));
    return idx >= 0 ? idx : fallbackIdx;
  }

  function loadHiddenArray(slug) {
    try { return JSON.parse(localStorage.getItem(LS_HIDDEN_SET(slug)) || '[]'); }
    catch { return []; }
  }
  function saveHiddenArray(slug, arr) {
    localStorage.setItem(LS_HIDDEN_SET(slug), JSON.stringify(arr));
  }

  async function fetchHTML(url) {
    try {
      const r = await fetch(url, { credentials: 'include' });
      if (!r.ok) return null;
      const html = await r.text();
      const parser = new DOMParser();
      return parser.parseFromString(html, 'text/html');
    } catch {
      return null;
    }
  }

  function injectStyles(css) {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  function flashStatus(msg) {
    status.textContent = msg;
    setTimeout(() => { if (status.textContent === msg) status.textContent = ''; }, 1200);
  }

  function updateCounts() {
    const totalOriginalRows = $$('tbody tr', table)
      .filter(tr => !tr.classList.contains('mg-filter-placeholder')).length;

    const filteredCount = $$('tbody tr.mg-filter-placeholder', table).length;
    const hiddenUserCount = $$('tbody tr.mg-hidden-user', table).length;

    const visibleOriginal = $$('tbody tr', table).filter(tr =>
      !tr.classList.contains('mg-filter-placeholder') &&
      !tr.classList.contains('mg-hidden-user') &&
      !tr.classList.contains('mg-hidden-filter-display')
    ).length;

    status.textContent = `${visibleOriginal}/${totalOriginalRows} visible • filtered: ${filteredCount} • hidden: ${hiddenUserCount}`;
  }

  function showBottomLoader(on) {
    bottomLoader.style.display = on ? 'block' : 'none';
    bottomLoader.textContent = on ? 'Loading…' : '';
  }

})();