Greasy Fork is available in English.

Nexus Mods - Updated Mod Highlighter

Highlight mods that have updated since you last downloaded them

// ==UserScript==
// @name          Nexus Mods - Updated Mod Highlighter
// @version       2.0.0
// @description   Highlight mods that have updated since you last downloaded them
// @author        Journey Over
// @license       MIT
// @match         *://www.nexusmods.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/utils/utils.min.js
// @grant         none
// @icon          https://www.google.com/s2/favicons?sz=64&domain=nexusmods.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  // ============================================================================
  // CONFIGURATION
  // ============================================================================

  /**
   * Application configuration object containing all customizable settings
   * @type {Object}
   */
  const CONFIG = {
    /** Table highlighting configuration */
    table: {
      highlightClass: 'nexus-updated-mod-highlight',
      highlightColor: 'rgba(0,180,255,0.1)', // Electric blue
    },

    /** Tile highlighting configuration */
    tile: {
      styleId: 'nm-highlighter-style',
      updateClass: 'nm-update-card',
      downloadClass: 'nm-downloaded-card',

      /** Color schemes for different highlight types */
      colors: {
        update: {
          primary: 'rgba(0,180,255,0.8)', // Electric blue
          secondary: 'rgba(0,240,255,0.6)', // Cyan
          glow: 'rgba(0,180,255,0.4)',
          bg: 'rgba(0,180,255,0.05)'
        },
        download: {
          primary: 'rgba(180,0,255,0.8)', // Electric purple
          secondary: 'rgba(255,0,180,0.6)', // Magenta
          glow: 'rgba(180,0,255,0.4)',
          bg: 'rgba(180,0,255,0.05)'
        }
      },

      /** CSS selectors for different tile types */
      selectors: [
        '[data-e2eid="mod-tile"]',
        '[data-e2eid="mod-tile-list"]',
        '[data-e2eid="mod-tile-standard"]',
        '[data-e2eid="mod-tile-compact"]',
        '[data-e2eid="mod-tile-teaser"]',
        '[class*="group/mod-tile"]'
      ],
    },

    /** Global style configuration */
    global: {
      styleId: 'nexus-global-style',
    },

    /** Performance settings */
    debounceDelay: 100,
  };

  // ============================================================================
  // CONSTANTS
  // ============================================================================

  /** Animation durations in seconds */
  const ANIMATION_DURATIONS = {
    TILE_GLOW: 2,
    TILE_PULSE: 2.5,
    TABLE_GLOW: 3,
    TABLE_STRIPE: 4,
    GRADIENT_SHIFT: 3,
  };

  /** CSS selectors for page detection */
  const PAGE_SELECTORS = {
    DOWNLOAD_HISTORY: {
      path: '/users/myaccount',
      tab: 'tab=download+history'
    }
  };

  // ============================================================================
  // GLOBAL VARIABLES
  // ============================================================================

  /** Logger instance for debugging */
  const logger = Logger('Nexus Mod - Updated Mod Highlighter', { debug: false });

  /** MutationObserver for dynamic content changes */
  let mutationObserver = null;

  // ============================================================================
  // UTILITY FUNCTIONS
  // ============================================================================

  /**
   * Parses date strings into timestamps
   * @param {string} text - Date string to parse
   * @returns {number} Timestamp or NaN if invalid
   */
  function parseDate(text) {
    if (!text) return NaN;

    const cleaned = text.replace(/\s+/g, ' ').trim();
    const parsed = Date.parse(cleaned);

    return isNaN(parsed) ? new Date(cleaned).getTime() || NaN : parsed;
  }

  /**
   * Checks if current page is the download history page
   * @returns {boolean} True if on download history page
   */
  function isDownloadHistoryPage() {
    return window.location.pathname.includes(PAGE_SELECTORS.DOWNLOAD_HISTORY.path) &&
      window.location.search.includes(PAGE_SELECTORS.DOWNLOAD_HISTORY.tab);
  }

  /**
   * Generates combined tile selector string
   * @returns {string} Combined CSS selector
   */
  function getTileSelector() {
    return CONFIG.tile.selectors.join(', ');
  }

  /**
   * Creates and injects a style element if it doesn't exist
   * @param {string} id - Style element ID
   * @param {string} css - CSS content
   * @param {string} description - Description for logging
   */
  function injectStyleElement(id, css, description) {
    if (document.getElementById(id)) return;

    const style = document.createElement('style');
    style.id = id;
    style.textContent = css;
    document.head.appendChild(style);

    logger.debug(`Injected ${description}`);
  }

  // ============================================================================
  // STYLE INJECTION FUNCTIONS
  // ============================================================================

  /**
   * Injects all required CSS styles
   */
  function injectStyles() {
    injectTableStyles();
    injectTileStyles();
    injectGlobalStyles();
  }

  /**
   * Injects table highlighting styles
   */
  function injectTableStyles() {
    const css = `
      @keyframes table-row-glow {
        0%, 100% {
          box-shadow:
            inset 0 0 8px rgba(0,180,255,0.1),
            0 0 4px rgba(0,180,255,0.2);
          background:
            linear-gradient(90deg,
              rgba(0,180,255,0.05) 0%,
              rgba(0,180,255,0.08) 50%,
              rgba(0,180,255,0.05) 100%);
        }
        50% {
          box-shadow:
            inset 0 0 12px rgba(0,180,255,0.15),
            0 0 8px rgba(0,180,255,0.3);
          background:
            linear-gradient(90deg,
              rgba(0,180,255,0.08) 0%,
              rgba(0,180,255,0.12) 50%,
              rgba(0,180,255,0.08) 100%);
        }
      }

      @keyframes table-stripe {
        0% { background-position: -200% 0; }
        100% { background-position: 200% 0; }
      }

      .${CONFIG.table.highlightClass} {
        position: relative;
        animation: table-row-glow ${ANIMATION_DURATIONS.TABLE_GLOW}s ease-in-out infinite;
        background:
          linear-gradient(90deg,
            rgba(0,180,255,0.03) 0%,
            rgba(0,180,255,0.06) 50%,
            rgba(0,180,255,0.03) 100%);
        background-size: 200% 100%;
        transition: all 0.3s ease;
      }

      .${CONFIG.table.highlightClass}::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background:
          linear-gradient(90deg,
            transparent 0%,
            rgba(0,180,255,0.1) 20%,
            rgba(0,180,255,0.2) 50%,
            rgba(0,180,255,0.1) 80%,
            transparent 100%);
        background-size: 200% 100%;
        animation: table-stripe ${ANIMATION_DURATIONS.TABLE_STRIPE}s linear infinite;
        pointer-events: none;
        z-index: 1;
      }

      .${CONFIG.table.highlightClass}::after {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        bottom: 0;
        width: 3px;
        background: linear-gradient(180deg,
          rgba(0,180,255,0.8) 0%,
          rgba(0,240,255,0.6) 50%,
          rgba(0,180,255,0.8) 100%);
        box-shadow: 0 0 8px rgba(0,180,255,0.4);
        z-index: 2;
      }

      .${CONFIG.table.highlightClass} td {
        position: relative;
        z-index: 3;
        color: inherit !important;
        font-weight: 500;
        transition: color 0.3s ease;
      }

      .${CONFIG.table.highlightClass}:hover {
        animation-duration: 1.5s;
        transform: translateY(-1px);
      }

      .${CONFIG.table.highlightClass}:hover::before {
        animation-duration: 2s;
      }
    `;

    injectStyleElement('nexus-updated-style', css, 'enhanced table styles');
  }

  /**
   * Injects tile highlighting styles
   */
  function injectTileStyles() {
    const css = `
      @keyframes nm-glow {
        0%, 100% {
          box-shadow:
            0 0 8px ${CONFIG.tile.colors.update.glow},
            0 0 16px ${CONFIG.tile.colors.update.glow.replace('0.4', '0.2')},
            0 0 24px ${CONFIG.tile.colors.update.glow.replace('0.4', '0.1')},
            inset 0 0 8px ${CONFIG.tile.colors.update.glow.replace('0.4', '0.1')};
          filter: brightness(1.05) saturate(1.1);
        }
        50% {
          box-shadow:
            0 0 12px ${CONFIG.tile.colors.update.primary.replace('0.8', '0.6')},
            0 0 24px ${CONFIG.tile.colors.update.primary.replace('0.8', '0.4')},
            0 0 36px ${CONFIG.tile.colors.update.primary.replace('0.8', '0.2')},
            inset 0 0 12px ${CONFIG.tile.colors.update.primary.replace('0.8', '0.15')};
          filter: brightness(1.08) saturate(1.15);
        }
      }

      @keyframes nm-download-pulse {
        0%, 100% {
          box-shadow:
            0 0 6px ${CONFIG.tile.colors.download.glow},
            0 0 12px ${CONFIG.tile.colors.download.glow.replace('0.4', '0.2')},
            inset 0 0 6px ${CONFIG.tile.colors.download.glow.replace('0.4', '0.05')};
        }
        50% {
          box-shadow:
            0 0 10px ${CONFIG.tile.colors.download.primary.replace('0.8', '0.5')},
            0 0 20px ${CONFIG.tile.colors.download.primary.replace('0.8', '0.3')},
            inset 0 0 10px ${CONFIG.tile.colors.download.primary.replace('0.8', '0.08')};
        }
      }

      .${CONFIG.tile.updateClass} {
        position: relative;
        background: linear-gradient(135deg,
          ${CONFIG.tile.colors.update.bg} 0%,
          ${CONFIG.tile.colors.update.bg.replace('0.05', '0.03')} 50%,
          ${CONFIG.tile.colors.update.bg.replace('0.05', '0.01')} 100%);
        border: 2px solid transparent;
        border-image: linear-gradient(135deg,
          ${CONFIG.tile.colors.update.primary} 0%,
          ${CONFIG.tile.colors.update.secondary} 50%,
          ${CONFIG.tile.colors.update.primary.replace('0.8', '0.4')} 100%);
        border-image-slice: 1;
        animation: nm-glow ${ANIMATION_DURATIONS.TILE_GLOW}s ease-in-out infinite;
        transform: scale(1.02);
        transition: all 0.3s ease;
      }

      .${CONFIG.tile.updateClass}::before {
        content: '';
        position: absolute;
        top: -2px;
        left: -2px;
        right: -2px;
        bottom: -2px;
        background: linear-gradient(45deg,
          transparent 0%,
          ${CONFIG.tile.colors.update.bg.replace('0.05', '0.1')} 25%,
          ${CONFIG.tile.colors.update.bg.replace('0.05', '0.2')} 50%,
          ${CONFIG.tile.colors.update.bg.replace('0.05', '0.1')} 75%,
          transparent 100%);
        background-size: 200% 200%;
        animation: gradient-shift ${ANIMATION_DURATIONS.GRADIENT_SHIFT}s ease-in-out infinite;
        pointer-events: none;
        z-index: -1;
      }

      .${CONFIG.tile.downloadClass} {
        position: relative;
        background: linear-gradient(135deg,
          ${CONFIG.tile.colors.download.bg} 0%,
          ${CONFIG.tile.colors.download.bg.replace('0.05', '0.03')} 50%,
          ${CONFIG.tile.colors.download.bg.replace('0.05', '0.01')} 100%);
        border: 2px solid transparent;
        border-image: linear-gradient(135deg,
          ${CONFIG.tile.colors.download.primary} 0%,
          ${CONFIG.tile.colors.download.secondary} 50%,
          ${CONFIG.tile.colors.download.primary.replace('0.8', '0.3')} 100%);
        border-image-slice: 1;
        animation: nm-download-pulse ${ANIMATION_DURATIONS.TILE_PULSE}s ease-in-out infinite;
        transform: scale(1.01);
        transition: all 0.3s ease;
      }

      .${CONFIG.tile.downloadClass}::after {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: radial-gradient(circle at center,
          ${CONFIG.tile.colors.download.bg.replace('0.05', '0.03')} 0%,
          transparent 70%);
        pointer-events: none;
        z-index: -1;
      }

      @keyframes gradient-shift {
        0% { background-position: 0% 0%; }
        50% { background-position: 100% 100%; }
        100% { background-position: 0% 0%; }
      }
    `;

    injectStyleElement(CONFIG.tile.styleId, css, 'enhanced tile styles');
  }

  /**
   * Injects global styles (border-radius removal)
   */
  function injectGlobalStyles() {
    const css = `*{border-radius: 0 !important;}`;
    injectStyleElement(CONFIG.global.styleId, css, 'global styles');
  }

  // ============================================================================
  // PROCESSING FUNCTIONS
  // ============================================================================

  /**
   * Processes table rows for highlighting updated mods
   */
  function processTable() {
    if (!isDownloadHistoryPage()) return;

    const rows = document.querySelectorAll('tr.even, tr.odd');
    let highlighted = 0;

    rows.forEach(row => {
      const downloadCell = row.querySelector('td.table-download');
      const updateCell = row.querySelector('td.table-update');

      if (!downloadCell || !updateCell) return;

      const downloadDate = parseDate(downloadCell.textContent);
      const updateDate = parseDate(updateCell.textContent);

      if (!isNaN(downloadDate) && !isNaN(updateDate) && downloadDate < updateDate) {
        row.classList.add(CONFIG.table.highlightClass);
        highlighted++;
      }
    });

    logger.debug(`Processed ${rows.length} table rows, highlighted ${highlighted}`);
  }

  /**
   * Processes mod tiles for highlighting based on badges
   */
  function processTiles() {
    if (isDownloadHistoryPage()) return;

    const tileSelector = getTileSelector();
    const tiles = document.querySelectorAll(tileSelector);

    // Clear existing highlights
    tiles.forEach(tile => {
      tile.classList.remove(CONFIG.tile.updateClass, CONFIG.tile.downloadClass);
    });

    // Apply highlights based on badges
    document.querySelectorAll('[data-e2eid="mod-tile-update-available"]').forEach(badge => {
      const tile = badge.closest(tileSelector);
      if (tile) tile.classList.add(CONFIG.tile.updateClass);
    });

    document.querySelectorAll('[data-e2eid="mod-tile-downloaded"]').forEach(badge => {
      const tile = badge.closest(tileSelector);
      if (tile && !tile.classList.contains(CONFIG.tile.updateClass)) {
        tile.classList.add(CONFIG.tile.downloadClass);
      }
    });

    logger.debug(`Processed ${tiles.length} tiles`);
  }

  /**
   * Processes both table and tiles based on current page
   */
  function processAll() {
    processTable();
    processTiles();
  }

  /**
   * Debounced version of processAll to prevent excessive processing
   */
  const debouncedProcess = debounce(processAll, CONFIG.debounceDelay);

  // ============================================================================
  // OBSERVER & EVENT SETUP
  // ============================================================================

  /**
   * Sets up MutationObserver for dynamic content changes
   */
  function setupMutationObserver() {
    if (mutationObserver) mutationObserver.disconnect();

    mutationObserver = new MutationObserver(debouncedProcess);
    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  /**
   * Sets up navigation event listeners for SPA support
   */
  function setupNavigationHooks() {
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function(...args) {
      const result = originalPushState.apply(this, args);
      debouncedProcess();
      return result;
    };

    history.replaceState = function(...args) {
      const result = originalReplaceState.apply(this, args);
      debouncedProcess();
      return result;
    };

    window.addEventListener('popstate', debouncedProcess);
  }

  // ============================================================================
  // INITIALIZATION
  // ============================================================================

  /**
   * Main initialization function
   */
  function init() {
    injectStyles();
    processAll();
    setupMutationObserver();
    setupNavigationHooks();
  }

  // ============================================================================
  // SCRIPT STARTUP
  // ============================================================================

  // Start the script when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();