// ==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 });
})();