SubsPlease Fine Enhancer

Adds image previews and AniList ratings to SubsPlease release listings. Click ratings to refresh. Settings via menu commands. Also manage favorites with visual highlights.

// ==UserScript==
// @name         SubsPlease Fine Enhancer
// @namespace    https://github.com/SonGokussj4/tampermonkey-subsplease-FineEnhancer
// @version      1.3.3
// @description  Adds image previews and AniList ratings to SubsPlease release listings. Click ratings to refresh. Settings via menu commands. Also manage favorites with visual highlights.
// @author       SonGokussj4
// @license      MIT
// @match        https://subsplease.org/
// @grant        GM_xmlhttpRequest
// @connect      graphql.anilist.co
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// ==/UserScript==

/* ------------------------------------------------------------------
 * CONFIG & CONSTANTS
 * ---------------------------------------------------------------- */
const DEBOUNCE_TIMER = 300; // ms
const CACHE_KEY = 'ratingCache';
const FAVORITES_KEY = 'spFavorites';
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours

// Menu commands for quick settings
GM_registerMenuCommand('Settings', showSettingsDialog);

/* ------------------------------------------------------------------
 * UTILITY FUNCTIONS
 * ---------------------------------------------------------------- */

/** Simple debounce wrapper */
function debounce(func, wait) {
  let timeout;
  return function (...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}

/** Normalize anime title:
 * - Remove episode markers like "— 01" / "- 03"
 * - Remove episode ranges like "— 01-24"
 * - Remove version markers like "— 01v2"
 * - Remove "(Batch)" or other bracketed notes at the end
 */
function normalizeTitle(raw) {
  const normalized = raw
    .replace(/\s*\(Batch\)$/i, '') // remove "(Batch)" suffix
    .replace(/\s*[–—-]\s*\d+(?:[vV]\d+)?(?:\s*-\s*\d+(?:[vV]\d+)?)?$/i, '')
    .trim();
  console.debug(`normalizeTitle: ${raw} --> ${normalized}`);
  return normalized;
}

/** Convert milliseconds → "Xh Ym" */
function msToTime(ms) {
  let totalSeconds = Math.floor(ms / 1000);
  let hours = Math.floor(totalSeconds / 3600);
  let minutes = Math.floor((totalSeconds % 3600) / 60);
  return `${hours}h ${minutes}m`;
}

/** Normalize CSS size input into "NNpx" */
function normalizeSize(raw) {
  if (typeof raw === 'number') return raw + 'px';
  if (typeof raw === 'string') {
    raw = raw.trim();
    if (/^\d+$/.test(raw)) return raw + 'px';
    if (/^\d+px$/.test(raw)) return raw;
  }
  return '64px';
}

function readRatingCache() {
  try {
    const raw = localStorage.getItem(CACHE_KEY);
    if (!raw) return {};
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch (e) {
    console.error('Failed to parse rating cache, clearing it.', e);
    localStorage.removeItem(CACHE_KEY);
    return {};
  }
}

function writeRatingCache(cache) {
  try {
    localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
  } catch (e) {
    console.error('Failed to write rating cache.', e);
  }
}

const mediaRegistry = new Map();
const ratingFetches = new Map();

function getMediaEntry(normalizedTitle) {
  let entry = mediaRegistry.get(normalizedTitle);
  if (!entry) {
    entry = {
      releaseWrappers: new Set(),
      releaseStars: new Set(),
      ratingSpans: new Set(),
      scheduleRows: new Set(),
      scheduleStars: new Set(),
      primaryTitle: null,
    };
    mediaRegistry.set(normalizedTitle, entry);
  }
  return entry;
}

function pruneDisconnected(set) {
  for (const node of set) {
    if (!node.isConnected) {
      set.delete(node);
    }
  }
}

function applyFavoriteVisuals(normalizedTitle, isFav) {
  const entry = getMediaEntry(normalizedTitle);

  pruneDisconnected(entry.releaseWrappers);
  for (const wrapper of entry.releaseWrappers) {
    wrapper.classList.toggle('sp-favorite', isFav);
  }

  pruneDisconnected(entry.releaseStars);
  for (const star of entry.releaseStars) {
    star.innerHTML = isFav ? '★' : '☆';
    star.style.color = isFav ? '#ffd700' : '#666';
    star.title = isFav ? 'Click to remove favorite' : 'Click to add favorite';
  }

  pruneDisconnected(entry.scheduleRows);
  for (const row of entry.scheduleRows) {
    row.classList.toggle('sp-schedule-favorite', isFav);
  }

  pruneDisconnected(entry.scheduleStars);
  for (const star of entry.scheduleStars) {
    star.innerHTML = isFav ? '★' : '☆';
    star.style.color = isFav ? '#ffd700' : '#666';
    star.title = isFav ? 'Click to remove favorite' : 'Click to add favorite';
  }
}

function refreshFavoriteVisuals(normalizedTitle) {
  const favorites = getFavorites();
  const isFav = !!favorites[normalizedTitle];
  applyFavoriteVisuals(normalizedTitle, isFav);
  return isFav;
}

function registerReleaseElements(normalizedTitle, { wrapper, star, ratingSpan, originalTitle }) {
  const entry = getMediaEntry(normalizedTitle);
  if (wrapper) entry.releaseWrappers.add(wrapper);
  if (star) entry.releaseStars.add(star);
  if (ratingSpan) entry.ratingSpans.add(ratingSpan);
  if (originalTitle && !entry.primaryTitle) entry.primaryTitle = originalTitle;
  refreshFavoriteVisuals(normalizedTitle);
}

function registerScheduleElements(normalizedTitle, { row, star, originalTitle }) {
  const entry = getMediaEntry(normalizedTitle);
  if (row) entry.scheduleRows.add(row);
  if (star) entry.scheduleStars.add(star);
  if (originalTitle && !entry.primaryTitle) entry.primaryTitle = originalTitle;
  refreshFavoriteVisuals(normalizedTitle);
}

/* ------------------------------------------------------------------
 * FAVORITES MANAGEMENT
 * ---------------------------------------------------------------- */

/** Get all favorites from localStorage */
function getFavorites() {
  try {
    return JSON.parse(localStorage.getItem(FAVORITES_KEY) || '{}');
  } catch (e) {
    console.error('Failed to parse favorites:', e);
    return {};
  }
}

/** Save favorites to localStorage */
function saveFavorites(favorites) {
  try {
    localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
  } catch (e) {
    console.error('Failed to save favorites:', e);
  }
}

/** Check if a show is favorited */
function isFavorite(title) {
  const normalizedTitle = normalizeTitle(title);
  const favorites = getFavorites();
  return !!favorites[normalizedTitle];
}

/** Toggle favorite status of a show */
function toggleFavorite(title) {
  const normalizedTitle = normalizeTitle(title);
  const favorites = getFavorites();
  let isFav;

  if (favorites[normalizedTitle]) {
    delete favorites[normalizedTitle];
    isFav = false;
  } else {
    favorites[normalizedTitle] = {
      originalTitle: title,
      timestamp: Date.now(),
    };
    isFav = true;
  }

  saveFavorites(favorites);
  applyFavoriteVisuals(normalizedTitle, isFav);
  return isFav;
}

/** Clear all favorites */
function clearAllFavorites() {
  if (confirm('Are you sure you want to clear all favorites? This cannot be undone.')) {
    localStorage.removeItem(FAVORITES_KEY);
    location.reload(); // Refresh to update UI
  }
}

/** Add favorite star to the time column */
function addFavoriteStar(cell, titleText, normalizedTitle) {
  // Find the table row and the time cell
  const row = cell.closest('tr');
  if (!row) return;

  const timeCell = row.querySelector('.release-item-time');
  if (!timeCell) return;

  // Create favorite star
  const favoriteSpan = document.createElement('span');
  favoriteSpan.className = 'sp-favorite-star';
  favoriteSpan.style.cursor = 'pointer';
  favoriteSpan.style.userSelect = 'none';
  favoriteSpan.innerHTML = '☆';
  favoriteSpan.style.color = '#666';
  favoriteSpan.title = 'Click to toggle favorite';
  favoriteSpan.dataset.title = titleText;
  favoriteSpan.dataset.normalizedTitle = normalizedTitle;

  favoriteSpan.addEventListener('click', (e) => {
    e.stopPropagation();
    toggleFavorite(titleText);
  });

  // Position the star at the top-right of the time cell
  timeCell.style.position = 'relative';
  timeCell.appendChild(favoriteSpan);

  return favoriteSpan;
}

/* ------------------------------------------------------------------
 * ANILIST FETCH + CACHE
 * ---------------------------------------------------------------- */

/** Perform AniList GraphQL request */
function gmFetchAniList(query, variables) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: 'POST',
      url: 'https://graphql.anilist.co',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      data: JSON.stringify({ query, variables }),
      onload: (response) => {
        try {
          console.log('Fetching ratings for:', JSON.stringify(variables.search));
          resolve(JSON.parse(response.responseText));
        } catch (e) {
          reject(e);
        }
      },
      onerror: reject,
    });
  });
}

/** Fetch AniList rating, with caching (6h TTL) */
async function fetchAniListRating(title, forceRefresh = false) {
  const now = Date.now();
  const cache = readRatingCache();
  const cleanTitle = normalizeTitle(title);
  const entry = cache[cleanTitle];
  const hasEntry = !!entry;
  const timestamp = entry?.timestamp ?? 0;
  const age = hasEntry ? now - timestamp : Infinity;
  const stale = hasEntry ? age >= CACHE_TTL_MS : false;

  if (hasEntry && !forceRefresh) {
    return {
      score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null,
      cached: true,
      stale,
      timestamp,
      expires: timestamp + CACHE_TTL_MS,
    };
  }

  const query = `
            query ($search: String) {
              Media(search: $search, type: ANIME) {
                averageScore
              }
            }
        `;

  try {
    const json = await gmFetchAniList(query, { search: cleanTitle });
    const score = json?.data?.Media?.averageScore ?? null;

    const latestCache = readRatingCache();
    latestCache[cleanTitle] = { score, timestamp: now };
    writeRatingCache(latestCache);

    return { score, cached: false, stale: false, timestamp: now, expires: now + CACHE_TTL_MS };
  } catch (err) {
    console.error('AniList fetch failed:', err);
    if (hasEntry) {
      return {
        score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null,
        cached: true,
        stale,
        timestamp,
        expires: timestamp + CACHE_TTL_MS,
        failed: true,
      };
    }
    return { score: null, cached: false, stale: false, timestamp: now, expires: now + CACHE_TTL_MS, failed: true };
  }
}

/* ------------------------------------------------------------------
 * RATING BADGE HANDLING
 * ---------------------------------------------------------------- */

function getCachedRatingData(normalizedTitle) {
  const cache = readRatingCache();
  const entry = cache[normalizedTitle];
  if (!entry) return null;
  const timestamp = entry?.timestamp ?? 0;
  const stale = Date.now() - timestamp >= CACHE_TTL_MS;
  return {
    score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null,
    cached: true,
    stale,
    timestamp,
    expires: timestamp + CACHE_TTL_MS,
    failed: false,
  };
}

function renderRatingSpan(span, data) {
  if (!span || !span.isConnected) return;

  if (data.loading) {
    span.textContent = '…';
    span.style.color = '#999';
    span.title = data.message || 'Loading rating…';
    return;
  }

  const hasScore = typeof data.score === 'number';

  if (!hasScore) {
    span.textContent = 'N/A';
    span.style.color = '#999';

    if (data.failed) {
      span.title = 'AniList fetch failed\nClick to retry';
    } else if (data.cached) {
      if (data.stale) {
        const age = Date.now() - (data.timestamp ?? Date.now());
        span.title = `AniList rating not available (${msToTime(age)} old)\nRefreshing… Click to force refresh`;
      } else {
        span.title = 'AniList rating not available\nClick to refresh';
      }
    } else {
      span.title = 'AniList rating not available\nClick to refresh';
    }
    return;
  }

  span.textContent = `⭐ ${data.score}%`;

  if (!data.cached) {
    span.style.color = data.failed ? '#cc4444' : '#00cc66';
    span.title = data.failed ? 'AniList fetch failed\nClick to retry' : 'Fresh from AniList\nClick to refresh';
    return;
  }

  const now = Date.now();
  const ageMs = now - (data.timestamp ?? now);

  if (data.stale) {
    span.style.color = '#cc8800';
    const staleDuration = msToTime(Math.max(0, ageMs - CACHE_TTL_MS));
    span.title = data.failed
      ? `Refresh failed — showing cached rating (expired ${staleDuration} ago)\nClick to retry`
      : `Using cached rating (${msToTime(ageMs)} old)\nRefreshing… Click to force refresh`;
    return;
  }

  const remaining = Math.max(0, (data.expires ?? data.timestamp + CACHE_TTL_MS) - now);
  span.style.color = '#ff9900';
  span.title = data.failed
    ? `Refresh failed — showing cached (expires in ${msToTime(remaining)})\nClick to retry`
    : `Loaded from cache (expires in ${msToTime(remaining)})\nClick to refresh`;
}

function renderRatingForTitle(normalizedTitle, data) {
  const entry = getMediaEntry(normalizedTitle);
  pruneDisconnected(entry.ratingSpans);
  for (const span of entry.ratingSpans) {
    renderRatingSpan(span, data);
  }
}

function setRefreshingState(normalizedTitle) {
  const entry = getMediaEntry(normalizedTitle);
  pruneDisconnected(entry.ratingSpans);
  for (const span of entry.ratingSpans) {
    span.title = 'Refreshing rating…';
  }
}

function ensureRatingForTitle(normalizedTitle, originalTitle, force = false) {
  const cachedData = getCachedRatingData(normalizedTitle);

  if (cachedData) {
    renderRatingForTitle(normalizedTitle, cachedData);
  } else {
    renderRatingForTitle(normalizedTitle, { loading: true });
  }

  const shouldFetch = force || !cachedData || cachedData.stale;
  if (!shouldFetch) {
    return Promise.resolve(cachedData);
  }

  setRefreshingState(normalizedTitle);

  if (ratingFetches.has(normalizedTitle)) {
    return ratingFetches.get(normalizedTitle);
  }

  const entry = getMediaEntry(normalizedTitle);
  const sourceTitle = originalTitle || entry.primaryTitle || normalizedTitle;

  const fetchPromise = fetchAniListRating(sourceTitle, true)
    .then((result) => {
      renderRatingForTitle(normalizedTitle, result);
      return result;
    })
    .catch((err) => {
      console.error('Failed to refresh rating:', err);
      const fallback = cachedData || {
        score: null,
        cached: false,
        stale: false,
        timestamp: Date.now(),
        expires: Date.now() + CACHE_TTL_MS,
        failed: true,
      };
      renderRatingForTitle(normalizedTitle, { ...fallback, failed: true });
      throw err;
    })
    .finally(() => {
      ratingFetches.delete(normalizedTitle);
    });

  ratingFetches.set(normalizedTitle, fetchPromise);
  return fetchPromise;
}

/** Attach rating badge to a title */
function addRatingToTitle(titleDiv, titleText, normalizedTitle) {
  const ratingSpan = document.createElement('span');
  ratingSpan.style.marginLeft = '8px';
  ratingSpan.style.cursor = 'pointer';
  ratingSpan.textContent = '…';
  ratingSpan.dataset.normalizedTitle = normalizedTitle;
  titleDiv.appendChild(ratingSpan);

  ratingSpan.addEventListener('click', (e) => {
    e.stopPropagation();
    ensureRatingForTitle(normalizedTitle, titleText, true);
  });

  return ratingSpan;
}

/* ------------------------------------------------------------------
 * IMAGE PREVIEW + STYLES
 * ---------------------------------------------------------------- */

function initScheduleFavorites() {
  const rows = document.querySelectorAll('#schedule-table tr.schedule-widget-item:not(.sp-schedule-processed)');
  if (!rows.length) return;

  rows.forEach((row) => {
    row.classList.add('sp-schedule-processed');
    const showCell = row.querySelector('.schedule-widget-show');
    const link = showCell?.querySelector('a');
    if (!showCell || !link) return;

    showCell.classList.add('sp-schedule-show');

    const titleText = link.textContent.trim();
    const normalizedTitle = normalizeTitle(titleText);

    const star = document.createElement('span');
    star.className = 'sp-schedule-favorite-star';
    star.innerHTML = '☆';
    star.style.cursor = 'pointer';
    star.style.userSelect = 'none';
    star.title = 'Click to toggle favorite';
    star.dataset.title = titleText;
    star.dataset.normalizedTitle = normalizedTitle;

    star.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      toggleFavorite(titleText);
    });

    showCell.appendChild(star);

    registerScheduleElements(normalizedTitle, { row, star, originalTitle: titleText });
  });
}

/** Inject styles (only once) */
function ensureStyles() {
  if (document.getElementById('sp-styles')) return;
  const css = `
    #releases-table td .sp-img-wrapper {
      display: flex;
      gap: 10px;
      align-items: flex-start;
      padding: 6px 0;
      transition: all 0.3s ease;
      border-radius: 8px;
      position: relative;
    }
    .sp-thumb {
      width: var(--sp-thumb-size, 64px);
      height: auto;
      object-fit: cover;
      border-radius: 6px;
      flex-shrink: 0;
      transition: all 0.3s ease;
    }
    .sp-text {
      display: flex;
      flex-direction: column;
      justify-content: flex-start;
    }
    .sp-title {
      font-weight: 600;
      margin-bottom: 4px;
    }
    .sp-badges {
      margin-top: 6px;
    }
    .sp-favorite {
      background: linear-gradient(90deg,
        rgba(255, 215, 0, 0.2) 0%,
        rgba(255, 215, 0, 0.14) 35%,
        rgba(255, 215, 0, 0.08) 70%,
        rgba(255, 215, 0, 0.03) 100%);
      border-left: 3px solid rgba(255, 215, 0, 0.75);
      padding-left: 10px;
      margin-left: -3px;
      box-shadow: 0 3px 12px rgba(255, 215, 0, 0.18);
    }
    .sp-favorite::before {
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      bottom: 0;
      width: 3px;
      background: linear-gradient(to bottom,
        rgba(255, 215, 0, 0.95) 0%,
        rgba(255, 215, 0, 0.45) 50%,
        rgba(255, 215, 0, 0.95) 100%);
      border-radius: 1px;
    }
    .sp-favorite .sp-thumb {
      box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
      border: 1px solid rgba(255, 215, 0, 0.45);
    }
    .sp-favorite:hover {
      background: linear-gradient(90deg,
        rgba(255, 215, 0, 0.24) 0%,
        rgba(255, 215, 0, 0.16) 35%,
        rgba(255, 215, 0, 0.1) 70%,
        rgba(255, 215, 0, 0.04) 100%);
    }
    .sp-favorite-star {
      position: absolute;
      top: 5px;
      right: 5px;
      font-size: 16px;
      z-index: 10;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
      transition: all 0.2s ease;
      opacity: 0.7;
      line-height: 1;
    }
    .sp-favorite-star:hover {
      opacity: 1;
      transform: scale(1.15);
    }
    .release-item-time {
      position: relative;
    }
    .sp-schedule-show {
      display: flex;
      align-items: center;
      gap: 8px;
      justify-content: space-between;
    }
    .sp-schedule-show a {
      flex: 1;
    }
    .sp-schedule-favorite {
      background: linear-gradient(90deg,
        rgba(255, 215, 0, 0.18) 0%,
        rgba(255, 215, 0, 0.1) 65%,
        rgba(255, 215, 0, 0.05) 100%);
      border-left: 3px solid rgba(255, 215, 0, 0.7);
    }
    .sp-schedule-favorite .schedule-widget-show a {
      font-weight: 600;
    }
    .sp-schedule-favorite-star {
      font-size: 16px;
      line-height: 1;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
      opacity: 0.75;
      transition: all 0.2s ease;
      color: #666;
    }
    .sp-schedule-favorite-star:hover {
      opacity: 1;
      transform: scale(1.15);
    }
    `;
  const style = document.createElement('style');
  style.id = 'sp-styles';
  style.textContent = css;
  document.head.appendChild(style);
}

/** Attach images + ratings to release table */
function addImages() {
  ensureStyles();
  initScheduleFavorites();

  // Load thumbnail size
  const thumbSize = normalizeSize(GM_getValue('imageSize', '64px'));
  document.documentElement.style.setProperty('--sp-thumb-size', thumbSize);

  const links = document.querySelectorAll('#releases-table a[data-preview-image]:not(.processed)');
  links.forEach((link) => {
    if (!link || link.classList.contains('processed')) return;
    link.classList.add('processed');

    const imgUrl = link.getAttribute('data-preview-image') || '';
    const cell = link.closest('td');
    if (!cell) return;

    // Build wrapper layout
    const wrapper = document.createElement('div');
    wrapper.className = 'sp-img-wrapper';

    const img = document.createElement('img');
    img.className = 'sp-thumb';
    img.src = imgUrl;
    img.alt = link.textContent.trim() || 'preview';

    const textDiv = document.createElement('div');
    textDiv.className = 'sp-text';

    const titleDiv = document.createElement('div');
    titleDiv.className = 'sp-title';
    titleDiv.appendChild(link);

    const titleText = link.textContent.trim();
    const normalizedTitle = normalizeTitle(titleText);

    // Add favorite star to time column
    const star = addFavoriteStar(cell, titleText, normalizedTitle);

    const ratingSpan = addRatingToTitle(titleDiv, titleText, normalizedTitle);

    const badge = cell.querySelector('.badge-wrapper');
    if (badge) {
      badge.classList.add('sp-badges');
      textDiv.appendChild(titleDiv);
      textDiv.appendChild(badge);
    } else {
      textDiv.appendChild(titleDiv);
    }

    wrapper.appendChild(img);
    wrapper.appendChild(textDiv);

    cell.innerHTML = '';
    cell.appendChild(wrapper);

    registerReleaseElements(normalizedTitle, { wrapper, star, ratingSpan, originalTitle: titleText });
    ensureRatingForTitle(normalizedTitle, titleText, false);
  });
}

/* ------------------------------------------------------------------
 * SETTINGS DIALOGS
 * ---------------------------------------------------------------- */

/** Modal to change script settings */
function showSettingsDialog() {
  const modal = document.createElement('div');
  modal.id = 'settingsModal';
  Object.assign(modal.style, {
    position: 'fixed',
    left: '0',
    top: '0',
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    zIndex: '9999',
  });

  const dialog = document.createElement('div');
  Object.assign(dialog.style, {
    backgroundColor: 'white',
    border: '1px solid #ccc',
    borderRadius: '5px',
    padding: '20px',
    width: '350px',
    boxShadow: '0 4px 6px rgba(50,50,93,0.11), 0 1px 3px rgba(0,0,0,0.08)',
  });

  // Image size section
  const imageSizeLabel = document.createElement('label');
  imageSizeLabel.textContent = 'Image Preview Size:';
  imageSizeLabel.style.display = 'block';
  imageSizeLabel.style.marginBottom = '8px';
  imageSizeLabel.style.fontWeight = 'bold';

  const select = document.createElement('select');
  select.id = 'imageSizeSelect';
  select.style.width = '100%';
  select.style.marginBottom = '20px';
  [
    { text: 'Small (64px)', value: '64px' },
    { text: 'Medium (128px)', value: '128px' },
    { text: 'Large (225px)', value: '225px' },
  ].forEach((item) => {
    const option = document.createElement('option');
    option.value = item.value;
    option.text = item.text;
    select.appendChild(option);
  });
  select.value = GM_getValue('imageSize', '64px');

  // Favorites section
  const favoritesLabel = document.createElement('label');
  favoritesLabel.textContent = 'Favorites Management:';
  favoritesLabel.style.display = 'block';
  favoritesLabel.style.marginBottom = '8px';
  favoritesLabel.style.fontWeight = 'bold';

  const favoritesCount = Object.keys(getFavorites()).length;
  const favoritesInfo = document.createElement('div');
  favoritesInfo.textContent = `Current favorites: ${favoritesCount}`;
  favoritesInfo.style.marginBottom = '10px';
  favoritesInfo.style.color = '#666';
  favoritesInfo.style.fontSize = '14px';

  const clearFavoritesButton = document.createElement('button');
  clearFavoritesButton.textContent = 'Clear All Favorites';
  Object.assign(clearFavoritesButton.style, {
    backgroundColor: '#dc3545',
    color: 'white',
    border: 'none',
    borderRadius: '5px',
    padding: '8px 16px',
    cursor: 'pointer',
    fontSize: '14px',
    width: '100%',
    marginBottom: '20px',
  });

  clearFavoritesButton.onclick = () => {
    document.body.removeChild(modal);
    clearAllFavorites();
  };

  // Main buttons
  const saveButton = document.createElement('button');
  saveButton.textContent = 'Save';
  Object.assign(saveButton.style, {
    backgroundColor: '#007BFF',
    color: 'white',
    border: 'none',
    borderRadius: '5px',
    padding: '10px 20px',
    cursor: 'pointer',
    fontSize: '16px',
  });

  saveButton.onclick = () => {
    GM_setValue('imageSize', select.value);
    document.body.removeChild(modal);
    document.documentElement.style.setProperty('--sp-thumb-size', select.value);
  };

  const closeButton = document.createElement('button');
  closeButton.textContent = 'Close';
  Object.assign(closeButton.style, {
    backgroundColor: '#6c757d',
    color: 'white',
    border: 'none',
    borderRadius: '5px',
    padding: '10px 20px',
    cursor: 'pointer',
    fontSize: '16px',
  });

  closeButton.onclick = () => {
    document.body.removeChild(modal);
  };

  const buttonsDiv = document.createElement('div');
  buttonsDiv.style.display = 'flex';
  buttonsDiv.style.gap = '10px';
  buttonsDiv.style.marginTop = '10px';
  buttonsDiv.appendChild(saveButton);
  buttonsDiv.appendChild(closeButton);

  dialog.appendChild(imageSizeLabel);
  dialog.appendChild(select);
  dialog.appendChild(favoritesLabel);
  dialog.appendChild(favoritesInfo);
  dialog.appendChild(clearFavoritesButton);
  dialog.appendChild(buttonsDiv);
  modal.appendChild(dialog);
  document.body.appendChild(modal);

  modal.addEventListener('click', (e) => {
    if (e.target === modal) {
      document.body.removeChild(modal);
    }
  });
}

/* ------------------------------------------------------------------
 * ENTRYPOINT: Mutation observer
 * ---------------------------------------------------------------- */
(function () {
  'use strict';

  const debouncedAddImages = debounce(addImages, DEBOUNCE_TIMER);

  const observer = new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
      if (mutation.type === 'childList') {
        for (const node of mutation.addedNodes) {
          if (node.nodeType === Node.ELEMENT_NODE && node.querySelector('a[data-preview-image]:not(.processed)')) {
            debouncedAddImages();
            return;
          }
        }
      }
    }
  });

  observer.observe(document.documentElement, { childList: true, subtree: true });
})();