// ==UserScript==
// @name Gelbooru Suite
// @namespace GelbooruEnhancer
// @version 2.0
// @description Enhances Gelbooru with thumbnail previews, a categorized pop-up search, an immersive viewer, and more.
// @author Testador (Refactored by Gemini)
// @match *://gelbooru.com/*
// @icon https://gelbooru.com/layout/gelbooru-logo.svg
// @grant GM_download
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_openInTab
// @license MIT
// @run-at document-idle
// ==/UserScript==
/* global navigatePrev, navigateNext */
(function() {
'use strict';
// =================================================================================
// CONFIGURATION AND CONSTANTS MODULE
// =================================================================================
const Config = {
API_URLS: {
BASE: 'https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1',
AUTOCOMPLETE: 'https://gelbooru.com/index.php?page=autocomplete2&type=tag_query&limit=10',
},
DEFAULT_SETTINGS: {
DEBUG: true,
// --- Global Toggles ---
ENABLE_ADVANCED_SEARCH: true,
ENABLE_PEEK_PREVIEWS: true,
ENABLE_ADD_TO_POOL: true,
ENABLE_DOWNLOADER: true,
HIDE_PAGE_SCROLLBARS: true,
BLACKLIST_TAGS: '',
// --- Downloader ---
DOWNLOAD_FOLDER: 'gelbooru',
// --- Previews ---
PREVIEW_QUALITY: 'high',
PREVIEW_SCALE_FACTOR: 2.5,
PREVIEW_VIDEOS_MUTED: true,
PREVIEW_VIDEOS_LOOP: true,
SHOW_DELAY: 350,
HIDE_DELAY: 175,
// --- Hotkeys ---
KEY_GALLERY_NEXT_PAGE: 'ArrowRight',
KEY_GALLERY_PREV_PAGE: 'ArrowLeft',
KEY_PEEK_VIDEO_PLAY_PAUSE: ' ',
KEY_PEEK_VIDEO_SEEK_BACK: 'ArrowLeft',
KEY_PEEK_VIDEO_SEEK_FORWARD: 'ArrowRight',
KEY_VIEWER_PREV_IMAGE: 'ArrowUp',
KEY_VIEWER_NEXT_IMAGE: 'ArrowDown',
KEY_VIEWER_TOGGLE_INFO: 'i',
// --- API ---
API_KEY: '',
USER_ID: '',
},
SELECTORS: {
SEARCH_INPUT: '#tags-search',
THUMBNAIL_GRID_SELECTOR: '.thumbnail-container, #post-list > div, .mainBodyPadding',
THUMBNAIL_ANCHOR_SELECTOR: '.thumbnail-preview > a',
VIDEO_PLAYER_SELECTOR: 'main video#gelcomVideoPlayer',
IMAGE_SELECTOR: 'main #image',
PAGINATION_CURRENT_SELECTOR: '.pagination b',
PREVIEW_CONTAINER_ID: 'enhancer-preview-container',
SETTINGS_MODAL_ID: 'enhancer-settings-modal',
ADVANCED_SEARCH_MODAL_ID: 'gbs-advanced-search-modal',
galleryNavSubmenu: '.navSubmenu',
postTagListItem: '#tag-list li[class*="tag-type-"]',
postTagLink: 'a[href*="&tags="]',
MEDIA_VIEWER_THUMBNAIL_ANCHOR: '.thumbnail-container > span > a, .thumbnail-container > .thumbnail-preview > a',
},
STORAGE_KEYS: {
SUITE_SETTINGS: 'gelbooruSuite_settings',
POOL_TARGET_ID: 'gbs_target_pool_id'
},
COLORS_CONSTANTS: {
'artist': '#AA0000',
'character': '#00AA00',
'copyright': '#AA00AA',
'metadata': '#FF8800',
'general': '#337ab7',
'excluded': '#d55e5e',
},
};
// =================================================================================
// GLOBAL STATE MODULE
// =================================================================================
const GlobalState = {
searchDebounceTimeout: null,
previewsTemporarilyDisabled: false, // Used to disable Peek
pageType: null,
};
// =================================================================================
// UTILITY, API, ZOOM & LOGGER MODULES (CORE)
// =================================================================================
const Utils = {
makeRequest: function(options) {
let xhr;
const promise = new Promise((resolve, reject) => {
xhr = GM.xmlHttpRequest({
...options,
onload: (response) => (response.status >= 200 && response.status < 300) ? resolve(response) : reject(new Error(`Request failed: Status ${response.status}`)),
onerror: (response) => reject(new Error(`Network error: ${response.statusText}`)),
ontimeout: () => reject(new Error('Request timed out.'))
});
});
return { promise, xhr };
},
getPostId: (postUrl) => new URL(postUrl).searchParams.get('id'),
formatHotkeyForStorage: function(key) {
return key === 'Space' ? ' ' : key.trim();
},
formatHotkeyForDisplay: function(key) {
return key === ' ' ? 'Space' : key;
},
};
const Logger = {
_log: function(level, ...args) {
if (!Settings.State.DEBUG) return;
const prefix = '[Gelbooru Suite]';
switch (level) {
case 'log': console.log(prefix, ...args); break;
case 'warn': console.warn(prefix, ...args); break;
case 'error': console.error(prefix, ...args); break;
default: console.log(prefix, ...args); break;
}
},
log: function(...args) { this._log('log', ...args); },
warn: function(...args) { this._log('warn', ...args); },
error: function(...args) { this._log('error', ...args); }
};
const API = {
fetchTagCategory: async function(tagName) {
const encodedTerm = encodeURIComponent(tagName.replace(/ /g, '_'));
const url = `${Config.API_URLS.AUTOCOMPLETE}&term=${encodedTerm}`;
try {
const { promise } = Utils.makeRequest({ method: "GET", url });
const response = await promise;
const data = JSON.parse(response.responseText);
if (data && data.length > 0) {
const exactMatch = data.find(tag => tag.value === tagName);
if (exactMatch) {
return exactMatch.category === 'tag' ? 'general' : exactMatch.category;
}
}
return 'general';
} catch (error) {
Logger.error(`Failed to fetch category for tag "${tagName}":`, error);
return 'general';
}
},
fetchTagSuggestions: async function(term) {
if (!term || term.length < 2) return [];
const encodedTerm = encodeURIComponent(term.replace(/ /g, '_'));
const url = `${Config.API_URLS.AUTOCOMPLETE}&term=${encodedTerm}`;
try {
const { promise } = Utils.makeRequest({ method: "GET", url });
const response = await promise;
const data = JSON.parse(response.responseText);
return data || [];
} catch (error) {
Logger.error("Failed to fetch tag suggestions:", error);
return [];
}
},
fetchMediaDetails: async function(postId) {
if (!postId) throw new Error("No Post ID provided.");
let request;
if (Settings.State.API_KEY && Settings.State.USER_ID) {
const apiUrl = `${Config.API_URLS.BASE}&id=${postId}&user_id=${Settings.State.USER_ID}&api_key=${Settings.State.API_KEY}`;
try {
request = Utils.makeRequest({ method: "GET", url: apiUrl });
Peek.State.pendingPreviewRequest = request.xhr;
const response = await request.promise;
if (response.status === 401 || response.status === 403) {
Settings.UI.openModal("Authentication failed. Your API Key or User ID is incorrect. Please enter valid credentials.");
throw new Error("Authentication failed. Please check your credentials.");
}
let data;
try { data = JSON.parse(response.responseText); }
catch (e) {
Logger.error("Failed to parse API response. It might not be valid JSON.", e);
Logger.error("Raw response text:", response.responseText);
throw new Error("Failed to parse API response. It might not be valid JSON.");
}
if (!data?.post?.length) throw new Error("API returned no post data or post not found.");
const post = data.post[0];
const fileUrl = post.file_url;
const isVideo = ['.mp4', '.webm'].some(ext => fileUrl.endsWith(ext));
return { url: fileUrl, type: isVideo ? 'video' : 'image' };
} catch (error) {
if (error.message.includes('abort')) {
Logger.log(`Request for post ${postId} was aborted.`);
throw error;
}
Logger.warn(`[Gelbooru Suite] API request failed: ${error.message}. Attempting HTML fallback.`);
} finally {
Peek.State.pendingPreviewRequest = null;
}
}
try {
Logger.log(`[Gelbooru Suite] Using HTML fallback for post ID: ${postId}`);
const postUrl = `https://gelbooru.com/index.php?page=post&s=view&id=${postId}`;
request = Utils.makeRequest({ method: "GET", url: postUrl });
Peek.State.pendingPreviewRequest = request.xhr;
const mediaData = await this.getPostData(request.promise);
return { url: mediaData.contentUrl, type: mediaData.type };
} catch (fallbackError) {
Logger.error('[Gelbooru Suite] HTML fallback also failed:', fallbackError);
throw fallbackError;
} finally {
Peek.State.pendingPreviewRequest = null;
}
},
fetchMediaDetailsFromHTML: async function(postUrl) {
if (!postUrl) throw new Error("No Post URL provided.");
try {
Logger.log(`[Gelbooru Suite] Using HTML-only fetch for: ${postUrl}`);
const { promise } = Utils.makeRequest({ method: "GET", url: postUrl });
const mediaData = await this.getPostData(promise);
return { url: mediaData.contentUrl, type: mediaData.type };
} catch (error) {
Logger.error('[Gelbooru Suite] HTML fetch failed:', error);
throw error;
}
},
fetchSavedSearches: async function(forceRefresh = false) {
const cacheKey = 'gbs_saved_searches_cache';
const cacheDuration = 6 * 60 * 60 * 1000; // 6 hour
if (!forceRefresh) {
const cachedData = await GM.getValue(cacheKey, null);
if (cachedData && (Date.now() - cachedData.timestamp < cacheDuration)) {
Logger.log("Using searches saved from the local cache.");
return cachedData.searches;
}
}
Logger.log(forceRefresh ? "Forcing cache refresh." : "Cache expired. Fetching from the network...");
let allSearches = [];
let nextPageUrl = '/index.php?page=tags&s=saved_search';
try {
while (nextPageUrl) {
const { promise } = Utils.makeRequest({ method: "GET", url: nextPageUrl });
const response = await promise;
const doc = new DOMParser().parseFromString(response.responseText, "text/html");
const searchNodes = doc.querySelectorAll('span[style*="font-size: 1.5em"] > a:nth-of-type(2)');
const searchesOnPage = Array.from(searchNodes).map(node => node.textContent.trim());
allSearches.push(...searchesOnPage);
const nextPageLink = doc.querySelector('.pagination b + a');
nextPageUrl = nextPageLink ? nextPageLink.getAttribute('href') : null;
}
await GM.setValue(cacheKey, { searches: allSearches, timestamp: Date.now() });
Logger.log(`Cache updated with ${allSearches.length} searches.`);
return allSearches;
} catch (error) {
Logger.error("Failed to fetch saved searches:", error);
const cachedData = await GM.getValue(cacheKey, null);
if (cachedData) {
Logger.warn("Using old cache data as fallback.");
return cachedData.searches;
}
return [];
}
},
getPostData: async function(requestPromise) {
const response = await requestPromise;
const doc = new DOMParser().parseFromString(response.responseText, "text/html");
const metaTag = doc.querySelector("meta[property='og:image']");
const videoTag = doc.querySelector("video#gelcomVideoPlayer source");
let contentUrl, type;
if (videoTag && videoTag.src) {
contentUrl = videoTag.src;
type = 'video';
} else if (metaTag) {
contentUrl = metaTag.getAttribute('content');
type = ['.mp4', '.webm'].some(ext => contentUrl.endsWith(ext)) ? 'video' : 'image';
}
if (contentUrl) {
return { contentUrl, type, tags: this.parseTags(doc) };
} else {
throw new Error(`Media not found for post.`);
}
},
parseTags: function(doc) {
const tags = {};
doc.querySelectorAll(Config.SELECTORS.postTagListItem).forEach(li => {
const categoryMatch = li.className.match(/tag-type-([a-z_]+)/);
const category = categoryMatch ? categoryMatch[1] : 'general';
const tagLink = li.querySelector(Config.SELECTORS.postTagLink);
if (tagLink) {
if (!tags[category]) { tags[category] = []; }
tags[category].push({ name: tagLink.textContent.trim(), url: tagLink.href });
}
});
return tags;
}
};
// =================================================================================
// SETTINGS MODULE
// =================================================================================
const Settings = {
State: {},
settingsMap: [
{ id: 'setting-advanced-search', key: 'ENABLE_ADVANCED_SEARCH', type: 'checkbox' },
{ id: 'setting-peek-previews', key: 'ENABLE_PEEK_PREVIEWS', type: 'checkbox' },
{ id: 'setting-add-to-pool', key: 'ENABLE_ADD_TO_POOL', type: 'checkbox' },
{ id: 'setting-downloader', key: 'ENABLE_DOWNLOADER', type: 'checkbox' },
{ id: 'setting-hide-scrollbars', key: 'HIDE_PAGE_SCROLLBARS', type: 'checkbox' },
{ id: 'setting-blacklist-tags', key: 'BLACKLIST_TAGS', type: 'textarea' },
{ id: 'setting-preview-quality', key: 'PREVIEW_QUALITY', type: 'select' },
{ id: 'setting-preview-scale', key: 'PREVIEW_SCALE_FACTOR', type: 'float' },
{ id: 'setting-preview-muted', key: 'PREVIEW_VIDEOS_MUTED', type: 'checkbox' },
{ id: 'setting-preview-loop', key: 'PREVIEW_VIDEOS_LOOP', type: 'checkbox' },
],
load: async function() {
const savedSettings = await GM.getValue(Config.STORAGE_KEYS.SUITE_SETTINGS, {});
this.State = { ...Config.DEFAULT_SETTINGS, ...savedSettings };
Logger.State = this.State; // Pass settings to logger
},
save: async function() {
const getHotkey = (id) => Utils.formatHotkeyForStorage(document.getElementById(id).value);
const newSettings = {};
this.settingsMap.forEach(setting => {
const element = document.getElementById(setting.id);
if (!element) return;
switch (setting.type) {
case 'checkbox':
newSettings[setting.key] = element.checked;
break;
case 'textarea':
case 'text':
case 'select':
newSettings[setting.key] = element.value.trim();
break;
case 'float':
newSettings[setting.key] = Math.max(1, parseFloat(element.value) || Config.DEFAULT_SETTINGS[setting.key]);
break;
}
});
Object.assign(newSettings, {
KEY_GALLERY_NEXT_PAGE: getHotkey('setting-key-gallery-next') || Config.DEFAULT_SETTINGS.KEY_GALLERY_NEXT_PAGE,
KEY_GALLERY_PREV_PAGE: getHotkey('setting-key-gallery-prev') || Config.DEFAULT_SETTINGS.KEY_GALLERY_PREV_PAGE,
KEY_PEEK_VIDEO_PLAY_PAUSE: getHotkey('setting-key-peek-vid-play') || Config.DEFAULT_SETTINGS.KEY_PEEK_VIDEO_PLAY_PAUSE,
KEY_PEEK_VIDEO_SEEK_FORWARD: getHotkey('setting-key-peek-vid-fwd') || Config.DEFAULT_SETTINGS.KEY_PEEK_VIDEO_SEEK_FORWARD,
KEY_PEEK_VIDEO_SEEK_BACK: getHotkey('setting-key-peek-vid-back') || Config.DEFAULT_SETTINGS.KEY_PEEK_VIDEO_SEEK_BACK,
KEY_VIEWER_PREV_IMAGE: getHotkey('setting-key-viewer-prev') || Config.DEFAULT_SETTINGS.KEY_VIEWER_PREV_IMAGE,
KEY_VIEWER_NEXT_IMAGE: getHotkey('setting-key-viewer-next') || Config.DEFAULT_SETTINGS.KEY_VIEWER_NEXT_IMAGE,
KEY_VIEWER_TOGGLE_INFO: getHotkey('setting-key-viewer-info') || Config.DEFAULT_SETTINGS.KEY_VIEWER_TOGGLE_INFO,
});
Object.assign(newSettings, {
API_KEY: document.getElementById('setting-api-key').value.trim(),
USER_ID: document.getElementById('setting-user-id').value.trim(),
DOWNLOAD_FOLDER: document.getElementById('setting-download-folder').value.trim() || Config.DEFAULT_SETTINGS.DOWNLOAD_FOLDER,
});
await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, newSettings);
this.State = newSettings;
this.UI.closeModal();
window.location.reload();
},
clearCredentials: async function() {
if (confirm("Are you sure you want to clear your API Key and User ID? The page will reload.")) {
const newSettings = { ...this.State, API_KEY: '', USER_ID: '' };
await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, newSettings);
this.State = newSettings;
window.location.reload();
}
},
export: function() {
const jsonString = JSON.stringify(this.State, null, 2);
const textarea = document.getElementById('enhancer-import-area');
const originalPlaceholder = textarea.placeholder;
const resetTextarea = () => {
textarea.placeholder = originalPlaceholder;
};
navigator.clipboard.writeText(jsonString).then(() => {
textarea.placeholder = 'Settings copied to clipboard!';
setTimeout(resetTextarea, 3000);
});
},
import: async function() {
const textarea = document.getElementById('enhancer-import-area');
const jsonString = textarea.value;
const originalPlaceholder = textarea.placeholder;
const showMessage = (message, duration = 3000) => {
textarea.placeholder = message;
setTimeout(() => {
textarea.placeholder = originalPlaceholder;
}, duration);
};
if (!jsonString.trim()) {
showMessage('Import field is empty.');
return;
}
try {
const importData = JSON.parse(jsonString);
if (importData && typeof importData === 'object') {
await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, importData);
showMessage('Settings imported! Page will reload...', 2000);
textarea.value = '';
setTimeout(() => window.location.reload(), 1500);
} else {
throw new Error("Invalid or incomplete settings format.");
}
} catch (error) {
showMessage(`Import failed: ${error.message}`, 4000);
Logger.error('Import error:', error);
}
},
testCredentials: async function() {
const testButton = document.getElementById('enhancer-test-creds');
const apiKey = document.getElementById('setting-api-key').value.trim();
const userId = document.getElementById('setting-user-id').value.trim();
const originalText = 'Test Connection';
const originalBgColor = testButton.style.backgroundColor;
const resetButton = (delay) => {
setTimeout(() => {
testButton.textContent = originalText;
testButton.style.backgroundColor = originalBgColor;
testButton.disabled = false;
}, delay);
};
testButton.disabled = true;
if (!apiKey || !userId) {
testButton.textContent = 'Missing Keys';
testButton.style.backgroundColor = '#EFB700';
resetButton(3000);
return;
}
testButton.textContent = 'Testing...';
testButton.style.backgroundColor = '#61afef';
const testUrl = `${Config.API_URLS.BASE}&limit=1&user_id=${userId}&api_key=${apiKey}`;
try {
const { promise } = Utils.makeRequest({ method: "GET", url: testUrl });
const response = await promise;
if (response.status === 200) {
testButton.textContent = 'Success!';
testButton.style.backgroundColor = '#008450';
} else {
throw new Error(`Authentication failed (Status: ${response.status})`);
}
} catch (error) {
Logger.error('API connection test failed:', error);
testButton.textContent = 'Error';
testButton.style.backgroundColor = '#B81D13';
} finally {
resetButton(3000);
}
},
UI: {
_getGeneralSettingsHTML: function() {
return `
<div class="settings-tab-pane active" data-tab="general">
<div class="setting-item"><label for="setting-advanced-search">Enable Tag Editor</label><label class="toggle-switch"><input type="checkbox" id="setting-advanced-search"><span class="toggle-slider"></span></label></div>
<div class="setting-item"><label for="setting-peek-previews">Enable Previews</label><label class="toggle-switch"><input type="checkbox" id="setting-peek-previews"><span class="toggle-slider"></span></label></div>
<div class="setting-item"><label for="setting-add-to-pool">Enable Add to Pool</label><label class="toggle-switch"><input type="checkbox" id="setting-add-to-pool"><span class="toggle-slider"></span></label></div>
<div class="setting-item"><label for="setting-downloader">Enable Downloader</label><label class="toggle-switch"><input type="checkbox" id="setting-downloader"><span class="toggle-slider"></span></label></div>
<div class="setting-item"><label for="setting-hide-scrollbars">Hide Page Scrollbars (Global)</label><label class="toggle-switch"><input type="checkbox" id="setting-hide-scrollbars"><span class="toggle-slider"></span></label></div>
<hr class="setting-divider">
<div class="setting-item-vertical">
<label for="setting-blacklist-tags">Blacklisted Tags (space-separated)</label>
<p class="setting-note" style="text-align: left; margin: 5px 0 10px 0;">Tags for the 'Toggle Blacklist' button in the Tag Editor modal.</p>
<textarea id="setting-blacklist-tags" rows="3" placeholder="Example: muscular red_eyes pov ..."></textarea>
</div>
</div>`;
},
_getPeekSettingsHTML: function() {
return `
<div class="settings-tab-pane" data-tab="peek">
<div class="setting-item"><label for="setting-preview-quality">Preview Quality</label><select id="setting-preview-quality"><option value="high">High</option><option value="low">Low</option></select></div>
<div class="setting-item"><label for="setting-preview-scale">Preview Scale Factor</label><input type="number" id="setting-preview-scale" step="0.1" min="1"></div>
<hr class="setting-divider">
<div class="setting-item"><label for="setting-preview-muted">Mute Preview Videos</label><label class="toggle-switch"><input type="checkbox" id="setting-preview-muted"><span class="toggle-slider"></span></label></div>
<div class="setting-item"><label for="setting-preview-loop">Loop Preview Videos</label><label class="toggle-switch"><input type="checkbox" id="setting-preview-loop"><span class="toggle-slider"></span></label></div>
</div>`;
},
_getHotkeysSettingsHTML: function() {
return `
<div class="settings-tab-pane" data-tab="hotkeys">
<p class="setting-note">Click on a field and press the desired key to set a hotkey.</p>
<h4 class="setting-subheader">Gallery Hotkeys</h4>
<div class="setting-item"><label for="setting-key-gallery-prev">Prev Page</label><input type="text" id="setting-key-gallery-prev" class="hotkey-input" readonly></div>
<div class="setting-item"><label for="setting-key-gallery-next">Next Page</label><input type="text" id="setting-key-gallery-next" class="hotkey-input" readonly></div>
<h4 class="setting-subheader">Preview Video Hotkeys</h4>
<div class="setting-item"><label for="setting-key-peek-vid-play">Play/Pause</label><input type="text" id="setting-key-peek-vid-play" class="hotkey-input" readonly></div>
<div class="setting-item"><label for="setting-key-peek-vid-back">Seek Back</label><input type="text" id="setting-key-peek-vid-back" class="hotkey-input" readonly></div>
<div class="setting-item"><label for="setting-key-peek-vid-fwd">Seek Forward</label><input type="text" id="setting-key-peek-vid-fwd" class="hotkey-input" readonly></div>
<h4 class="setting-subheader">Media Viewer Hotkeys</h4>
<div class="setting-item"><label for="setting-key-viewer-prev">Prev Image</label><input type="text" id="setting-key-viewer-prev" class="hotkey-input" readonly></div>
<div class="setting-item"><label for="setting-key-viewer-next">Next Image</label><input type="text" id="setting-key-viewer-next" class="hotkey-input" readonly></div>
<div class="setting-item"><label for="setting-key-viewer-info">Toggle Info</label><input type="text" id="setting-key-viewer-info" class="hotkey-input" readonly></div>
</div>`;
},
_getAdvancedSettingsHTML: function() {
return `
<div class="settings-tab-pane" data-tab="advanced">
<p class="setting-note">API keys can improve script reliability. Find them in Settings > Options.</p>
<p class="setting-note"><strong>Security Note: Keys are stored locally and are not encrypted.</strong></p>
<div class="setting-item"><label for="setting-api-key">API Key</label><input type="text" id="setting-api-key" placeholder="Your API key"></div>
<div class="setting-item"><label for="setting-user-id">User ID</label><input type="text" id="setting-user-id" placeholder="Your user ID"></div>
<div class="api-test-container">
<button id="enhancer-test-creds">Test Connection</button>
</div>
<p class="enhancer-error-message" id="enhancer-auth-error" style="display:none; text-align: center;"></p>
<hr class="setting-divider">
<div class="setting-item">
<label for="setting-download-folder">Download Folder</label>
<input type="text" id="setting-download-folder" placeholder="e.g., gelbooru_downloads">
</div>
<hr class="setting-divider">
<div class="manage-settings-section">
<p class="setting-note">Export your settings for backup, or import them on another browser.</p>
<div class="manage-buttons">
<button id="enhancer-export-settings">Export</button>
<button id="enhancer-import-settings">Import</button>
</div>
<textarea id="enhancer-import-area" rows="3" placeholder="Paste your exported settings string here and click Import..."></textarea>
</div>
</div>`;
},
createModal: function() {
if (document.getElementById(Config.SELECTORS.SETTINGS_MODAL_ID)) return;
GM_addStyle(`
#${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 100000; justify-content: center; align-items: center; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} { box-sizing: border-box !important; background-color: #252525; color: #eee !important; border: 1px solid #666; border-radius: 10px; z-index: 101; padding: 20px 7px 7px 7px; width: 90%; max-width: 450px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} * { color: #eee !important; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} h2 { text-align: center; margin-bottom: 10px; padding-bottom: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tabs { display: flex; margin-bottom: 15px; border-bottom: 1px solid #555; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-btn { background: none; border: none; color: #aaa !important; padding: 8px 12px; cursor: pointer; font-size: 1em; border-bottom: 2px solid transparent; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-btn.active { color: #fff !important; border-bottom-color: #006FFA; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-content { display: grid; align-items: start; padding-top: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-pane { grid-row: 1; grid-column: 1; opacity: 0; pointer-events: none; transition: opacity 0.15s ease-in-out; max-height: 65vh; overflow-y: auto; padding: 3px 15px; box-sizing: border-box !important; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-pane.active { opacity: 1; pointer-events: auto; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item-vertical { display: flex; flex-direction: column; margin-bottom: 12px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item-vertical label { margin-bottom: 8px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item-vertical textarea, #${Config.SELECTORS.SETTINGS_MODAL_ID} #enhancer-import-area { width: 100%; box-sizing: border-box !important; padding: 5px; background: #333; border: 1px solid #555; color: #fff !important; border-radius: 10px; resize: vertical; height: 70px; margin-top: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item label { color: #eee !important; flex-shrink: 0; font-weight: 300 }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item input[type=number], #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item input[type=text], #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item select { width: 120px; box-sizing: border-box !important; padding: 6px; background: #333; border: 1px solid #555; color: #fff !important; border-radius: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item input[type=range] { flex-grow: 1; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-note { font-size: 11px; color: #999 !important; text-align: center; margin-top: 5px; margin-bottom: 15px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-note strong { color: #daa520 !important; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-divider { border: 0; height: 1px; background: #444; margin: 20px 0; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-subheader { color: #006FFA !important; border-bottom: 1px solid #444; padding-bottom: 5px; margin-top: 20px; margin-bottom: 15px; font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.5px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-buttons { text-align: right; margin-top: 20px; border-top: 1px solid #555; padding-top: 15px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-buttons button { padding: 8px 12px; border: none; background-color: #444; color: #fff !important; border-radius: 10px; cursor: pointer; font-weight: bold }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .manage-buttons button { background-color: #444; color: #fff !important; border: 1px solid #666; padding: 8px 12px; border-radius: 10px; cursor: pointer; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .manage-buttons button:hover { background-color: #555; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} #enhancer-save-settings { background-color: #006FFA; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} #enhancer-clear-creds { background-color: #A43535; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} #zoom-sens-value { color: #fff !important; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .api-test-container { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 10px; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} #enhancer-test-creds { padding: 5px 10px; font-size: 0.9em; background-color: #006FFA; border-radius: 3px; font-weight: bold; min-width: 110px; text-align: center; box-sizing: border-box !important; transition: background-color 0.2s ease-in-out; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .hotkey-input { cursor: pointer; text-align: center; }
#${Config.SELECTORS.SETTINGS_MODAL_ID} .hotkey-input:focus { background-color: #006FFA; color: #000 !important; font-weight: bold; }
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #555; transition: .4s; border-radius: 25px; }
.toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .toggle-slider { background-color: #006FFA; }
input:checked + .toggle-slider:before { transform: translateX(18px); }
`);
const modalHTML = `
<div id="${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay">
<div id="${Config.SELECTORS.SETTINGS_MODAL_ID}">
<h2>Gelbooru Suite Settings</h2>
<div class="settings-tabs">
<button class="settings-tab-btn active" data-tab="general">General</button>
<button class="settings-tab-btn" data-tab="peek">Previews</button>
<button class="settings-tab-btn" data-tab="hotkeys">Hotkeys</button>
<button class="settings-tab-btn" data-tab="advanced">Advanced</button>
</div>
<div class="settings-tab-content">
${this._getGeneralSettingsHTML()}
${this._getPeekSettingsHTML()}
${this._getHotkeysSettingsHTML()}
${this._getAdvancedSettingsHTML()}
</div>
<div class="footer-container" style="text-align: right; border-top: 1px solid #555; padding-top: 15px; margin-top: 20px;">
<div class="settings-buttons" style="margin-top: 0; border-top: none; padding-top: 0;">
<button id="enhancer-clear-creds">Clear Credentials</button>
<button id="enhancer-save-settings">Save</button>
<button id="enhancer-close-settings">Close</button>
</div>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
document.getElementById('enhancer-save-settings').addEventListener('click', Settings.save.bind(Settings));
document.getElementById('enhancer-clear-creds').addEventListener('click', Settings.clearCredentials.bind(Settings));
document.getElementById('enhancer-close-settings').addEventListener('click', this.closeModal);
document.getElementById('enhancer-export-settings').addEventListener('click', Settings.export.bind(Settings));
document.getElementById('enhancer-import-settings').addEventListener('click', Settings.import.bind(Settings));
document.getElementById('enhancer-test-creds').addEventListener('click', Settings.testCredentials.bind(Settings));
document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`).addEventListener('click', (e) => {
if (e.target.id === `${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`) this.closeModal();
});
const tabsContainer = document.querySelector(`#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tabs`);
const panesContainer = document.querySelector(`#${Config.SELECTORS.SETTINGS_MODAL_ID} .settings-tab-content`);
tabsContainer.addEventListener('click', (e) => {
if (e.target.matches('.settings-tab-btn')) {
const targetTab = e.target.dataset.tab;
if (tabsContainer.querySelector('.active')) { tabsContainer.querySelector('.active').classList.remove('active'); }
e.target.classList.add('active');
if (panesContainer.querySelector('.active')) { panesContainer.querySelector('.active').classList.remove('active'); }
panesContainer.querySelector(`.settings-tab-pane[data-tab="${targetTab}"]`).classList.add('active');
}
});
document.querySelectorAll('.hotkey-input').forEach(input => {
const handleKeyDown = (e) => {
e.preventDefault();
let key = e.key;
if (key === ' ') { key = 'Space'; }
input.value = key;
input.blur();
};
const handleFocus = () => {
input.value = 'Press a key...';
input.addEventListener('keydown', handleKeyDown, { once: true });
};
const handleBlur = () => {
if (input.value === 'Press a key...') {
const settingKey = input.id.replace('setting-key-', 'KEY_').toUpperCase().replace(/-/g, '_');
let defaultValue = Settings.State[settingKey] || Config.DEFAULT_SETTINGS[settingKey];
input.value = Utils.formatHotkeyForDisplay(defaultValue);
}
input.removeEventListener('keydown', handleKeyDown);
};
input.addEventListener('focus', handleFocus);
input.addEventListener('blur', handleBlur);
});
},
openModal: function(authError = '') {
if (!document.getElementById(Config.SELECTORS.SETTINGS_MODAL_ID)) { this.createModal(); }
Settings.settingsMap.forEach(setting => {
const element = document.getElementById(setting.id);
if (!element) return;
if (setting.type === 'checkbox') {
element.checked = Settings.State[setting.key];
} else {
element.value = Settings.State[setting.key];
}
});
document.getElementById('setting-key-gallery-next').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_GALLERY_NEXT_PAGE);
document.getElementById('setting-key-gallery-prev').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_GALLERY_PREV_PAGE);
document.getElementById('setting-key-peek-vid-play').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_PEEK_VIDEO_PLAY_PAUSE);
document.getElementById('setting-key-peek-vid-fwd').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_PEEK_VIDEO_SEEK_FORWARD);
document.getElementById('setting-key-peek-vid-back').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_PEEK_VIDEO_SEEK_BACK);
document.getElementById('setting-key-viewer-prev').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_VIEWER_PREV_IMAGE);
document.getElementById('setting-key-viewer-next').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_VIEWER_NEXT_IMAGE);
document.getElementById('setting-key-viewer-info').value = Utils.formatHotkeyForDisplay(Settings.State.KEY_VIEWER_TOGGLE_INFO);
document.getElementById('setting-api-key').value = Settings.State.API_KEY;
document.getElementById('setting-user-id').value = Settings.State.USER_ID;
document.getElementById('setting-download-folder').value = Settings.State.DOWNLOAD_FOLDER;
const errorMessageElement = document.getElementById('enhancer-auth-error');
if (authError) {
errorMessageElement.textContent = authError;
errorMessageElement.style.display = 'block';
document.querySelector('.settings-tabs .settings-tab-btn[data-tab="advanced"]').click();
} else {
errorMessageElement.style.display = 'none';
}
document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`).style.display = 'flex';
},
closeModal: function() {
const overlay = document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`);
if (overlay) overlay.style.display = 'none';
},
}
};
// =================================================================================
// ADVANCED SEARCH MODULE
// =================================================================================
const AdvancedSearch = {
init: function() {
const originalInput = document.querySelector(Config.SELECTORS.SEARCH_INPUT);
if (!originalInput) return;
this.injectStyles();
if (originalInput.form) {
originalInput.form.classList.add('gbs-search-form');
}
const { openModal } = this.UI.createModal(originalInput);
const advButton = document.createElement('button');
advButton.type = 'button';
advButton.textContent = 'Tag Editor';
advButton.id = 'gbs-advanced-search-btn';
advButton.title = 'Open Advanced Tag Editor';
const originalSubmitButton = originalInput.form.querySelector('input[name="commit"]');
if (originalSubmitButton) {
originalSubmitButton.insertAdjacentElement('afterend', advButton);
} else {
originalInput.insertAdjacentElement('afterend', advButton);
}
advButton.addEventListener('click', (e) => {
e.preventDefault();
openModal();
});
},
injectStyles: function() {
GM_addStyle(`
.gbs-search-form { display: flex; align-items: center; gap: 5px; }
.gbs-search-form > p { display: contents; }
.gbs-search-form #tags-search { flex-grow: 1; width: auto !important; }
.gbs-search-form input[type="submit"] { flex-shrink: 0; }
#${Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID}-overlay { display: flex; opacity: 0; pointer-events: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 100001; justify-content: center; align-items: flex-start; padding-top: 5vh; font-family: sans-serif; }
#${Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID} { background-color: #252525; border-radius: 10px; box-shadow: 0 5px 25px rgba(0,0,0,0.5); width: 90%; max-width: 550px; padding: 20px 7px 7px 7px; border: 1px solid #666; height: 80vh; display: flex; flex-direction: column; }
.gbs-search-title { margin-bottom: 10px; padding-bottom: 10px; color: #eee; text-align: center; flex-shrink: 0; font-family: verdana, helvetica; }
.gbs-input-wrapper { position: relative; flex-shrink: 0; }
#gbs-tag-input { width: 100%; box-sizing: border-box; background: #333; border: 1px solid #555; padding-left: 40px !important; padding: 10px; border-radius: 10px; font-size: 1em; color: #eee; }
.gbs-suggestion-container { position: absolute; top: 100%; left: 0; right: 0; background-color: #1F1F1F; border: 1px solid #555; border-top: none; z-index: 100002; max-height: 200px; overflow-y: auto; display: none; box-shadow: 0 4px 6px rgba(0,0,0,0.2); }
.gbs-suggestion-item { font-size: 1.08em; padding: 3px 10px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; color: #ddd; }
.gbs-suggestion-item:hover { background-color: #E6E6FA; }
.gbs-suggestion-label { display: flex; align-items: center; gap: 8px; }
.gbs-suggestion-category { font-size: 0.8em; color: #999; text-transform: capitalize; }
.gbs-suggestion-count { font-size: 0.9em; }
#gbs-category-sections-wrapper { overflow-y: auto; margin-top: 15px; padding-right: 15px; flex-grow: 1; min-height: 80px; }
.gbs-category-section { margin-bottom: 10px; }
.gbs-category-title { color: #eee; margin: 0 0 8px 0; padding-bottom: 4px; border-bottom: 2px solid; font-size: 1em; text-transform: capitalize; }
.gbs-pill-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 0; min-height: 20px; }
.gbs-tag-pill { display: inline-flex; align-items: center; color: white; padding: 5px 10px; border-radius: 10px; font-size: 0.9em; font-weight: bold; text-shadow: 1px 1px 3px rgba(0,0,0,0.9); }
.gbs-remove-tag-btn { margin-left: 8px; cursor: pointer; font-style: normal; font-weight: bold; line-height: 1; padding: 2px; font-size: 1.2em; opacity: 0.7; }
.gbs-remove-tag-btn:hover { opacity: 1; }
.gbs-modal-actions { display: inline-flex; justify-content: flex-end; gap: 10px; margin-top: 15px; border-top: 1px solid #555; padding-top: 15px; flex-shrink: 0; }
.gbs-modal-button, .gbs-modal-button-primary { padding: 8px 12px; border:none; border-radius: 10px; cursor: pointer; font-weight: bold; }
#gbs-blacklist-toggle { min-width: 145px; text-align: center; }
.gbs-modal-button { background-color: #444; color: #fff; }
.gbs-modal-button-primary { background-color: #006FFA; color: white; }
#gbs-advanced-search-btn { margin-left: 0px; padding: 7px 15px; vertical-align: top; cursor: pointer; background: #333333; color: #EEEEEE; border: 1px solid #555555; font-weight: bold; }
#gbs-advanced-search-btn:hover { background: #555; }
.gbs-modifiers-section { margin-top: 15px; padding: 10px; background-color: rgba(0,0,0,0.2); border-radius: 10px; border: 1px solid #555; }
.gbs-modifiers-title { margin: 0 0 12px 0; font-size: 1em; color: #ccc; border-bottom: 1px solid #555; padding-bottom: 5px; }
.gbs-modifier-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.gbs-modifier-row label { font-weight: bold; color: #ddd; flex-basis: 30%; }
.gbs-modifier-row select, .gbs-modifier-row input { flex-grow: 1; background: #333; border: 1px solid #555; padding: 5px; border-radius: 10px; color: #eee; width: 110px; }
.gbs-score-group { display: flex; gap: 5px; flex-grow: 1; }
.gbs-score-group select { width: 60px; flex-grow: 0; }
#gbs-saved-searches-toggle-btn { position: absolute; left: 5px; top: 50%; transform: translateY(-50%); cursor: pointer; transition: color 0.2s; font-size: 1.8em }
#gbs-saved-searches-toggle-btn:hover,
#gbs-saved-searches-toggle-btn.active { color: #daa520 !important; }
#gbs-modifiers-toggle-btn { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); cursor: pointer; transition: color 0.2s; font-size: 1.8em; }
#gbs-modifiers-toggle-btn:hover,
#gbs-modifiers-toggle-btn.active { color: #006FFA !important; }
#gbs-modifiers-panel.hidden { display: none; }
#gbs-saved-searches-panel { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; max-height: 20vh; overflow-y: auto; }
#gbs-saved-searches-panel.hidden { display: none; }
.gbs-modal-saved-search { background-color: #4a4a4a; color: #ddd; padding: 4px 8px; border-radius: 10px; font-size: 1em; cursor: pointer; transition: background-color .2s, border-color .2s; user-select: none; text-shadow: 1px 1px 3px rgba(0,0,0,0.9); }
.gbs-modal-saved-search:hover { background-color: #daa520; border-color: #daa520; color: white; }
@media (max-width: 850px) {
.gbs-search-form { flex-wrap: wrap; }
#gbs-advanced-search-btn { flex-grow: 1; width: auto !important; background: #666 !important;}
.searchList { width: 70px; }
}
`);
},
UI: {
createModal: function(originalInput) {
if (document.getElementById(Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID)) return;
const modalOverlay = document.createElement('div');
modalOverlay.id = `${Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID}-overlay`;
const modalPanel = document.createElement('div');
modalPanel.id = Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID;
let categorySectionsHTML = `
<div class="gbs-category-section" id="gbs-section-main">
<h4 class="gbs-category-title" style="border-color: #006FFA">Tags</h4>
<div class="gbs-pill-container" id="gbs-pill-container-main"></div>
</div>
<div class="gbs-category-section" id="gbs-section-excluded">
<h4 class="gbs-category-title" style="border-color: ${Config.COLORS_CONSTANTS.excluded}">Excluded</h4>
<div class="gbs-pill-container" id="gbs-pill-container-excluded"></div>
</div>
`;
modalPanel.innerHTML = `
<h2 class="gbs-search-title">Advanced Tag Editor</h2>
<div class="gbs-input-wrapper">
<input type="text" id="gbs-tag-input" placeholder="Use '-' to exclude. Press space for '_'.">
<i class="fas fa-star" id="gbs-saved-searches-toggle-btn" title="Show Saved Searches"></i>
<i class="fas fa-cog" id="gbs-modifiers-toggle-btn" title="Show Search Modifiers"></i>
<div class="gbs-suggestion-container" id="gbs-main-suggestion-container"></div>
</div>
<div id="gbs-saved-searches-panel" class="hidden"></div>
<div id="gbs-modifiers-panel" class="gbs-modifiers-section hidden">
<h4 class="gbs-modifiers-title">Search Modifiers</h4>
<div class="gbs-modifier-row">
<label for="gbs-sort-select">Sort By</label>
<select id="gbs-sort-select">
<option value="">Default (ID)</option>
<option value="random">Random</option>
<option value="score">Score (High to Low)</option>
<option value="score:asc">Score (Low to High)</option>
<option value="updated:desc">Updated Date</option>
<option value="id:asc">ID (Ascending)</option>
</select>
</div>
<div class="gbs-modifier-row">
<label for="gbs-rating-select">Rating</label>
<select id="gbs-rating-select">
<option value="">Any</option>
<option value="explicit">Explicit</option>
<option value="questionable">Questionable</option>
<option value="sensitive">Sensitive</option>
<option value="general">General</option>
</select>
</div>
<div class="gbs-modifier-row">
<label for="gbs-score-value">Score</label>
<div class="gbs-score-group">
<select id="gbs-score-operator">
<option value=">=">≥</option>
<option value="<=">≤</option>
<option value=">">></option>
<option value="<"><</option>
<option value="">=</option>
</select>
<input type="number" id="gbs-score-value" placeholder="e.g., 50">
</div>
</div>
</div>
<div id="gbs-category-sections-wrapper">${categorySectionsHTML}</div>
<div class="gbs-modal-actions">
<button id="gbs-blacklist-toggle" class="gbs-modal-button" style="margin-right: auto;">Toggle Blacklist</button>
<button id="gbs-search-apply" class="gbs-modal-button-primary">Search</button>
<button id="gbs-search-close" class="gbs-modal-button">Close</button>
</div>
`;
modalOverlay.appendChild(modalPanel);
document.body.appendChild(modalOverlay);
const tagInput = modalPanel.querySelector('#gbs-tag-input');
const suggestionBox = modalPanel.querySelector('#gbs-main-suggestion-container');
const applyBtn = modalPanel.querySelector('#gbs-search-apply');
const closeBtn = modalPanel.querySelector('#gbs-search-close');
const blacklistToggleBtn = modalPanel.querySelector('#gbs-blacklist-toggle');
const sortSelect = modalPanel.querySelector('#gbs-sort-select');
const ratingSelect = modalPanel.querySelector('#gbs-rating-select');
const scoreOp = modalPanel.querySelector('#gbs-score-operator');
const scoreVal = modalPanel.querySelector('#gbs-score-value');
let syncToOriginalInput = () => {
const regularPills = modalPanel.querySelectorAll('.gbs-pill-container .gbs-tag-pill');
const regularTags = Array.from(regularPills).map(pill => pill.dataset.value);
const modifiers = [];
if (sortSelect.value) {
modifiers.push(`sort:${sortSelect.value}`);
}
if (ratingSelect.value) {
modifiers.push(`rating:${ratingSelect.value}`);
}
if (scoreVal.value) {
modifiers.push(`score:${scoreOp.value}${scoreVal.value}`);
}
const allTags = [...modifiers, ...regularTags];
originalInput.value = allTags.join(' ');
};
sortSelect.addEventListener('change', syncToOriginalInput);
ratingSelect.addEventListener('change', syncToOriginalInput);
scoreOp.addEventListener('change', syncToOriginalInput);
scoreVal.addEventListener('input', syncToOriginalInput);
const parseAndSetModifiers = (tagsArray) => {
const remainingTags = [];
tagsArray.forEach(tag => {
if (tag.startsWith('sort:')) {
sortSelect.value = tag.substring(5);
} else if (tag.startsWith('rating:')) {
ratingSelect.value = tag.substring(7);
} else if (tag.startsWith('score:')) {
const match = tag.match(/score:([><=]*)(\d+)/);
if (match) {
const [, op, val] = match;
scoreOp.value = op || '';
scoreVal.value = val;
}
} else {
remainingTags.push(tag);
}
});
return remainingTags;
};
const updateBlacklistButton = () => {
const blacklistTags = (Settings.State.BLACKLIST_TAGS || '').trim().split(/\s+/).filter(Boolean);
if (blacklistTags.length === 0) {
blacklistToggleBtn.style.display = 'none';
return;
}
blacklistToggleBtn.style.display = '';
const tagsAsPills = Array.from(modalPanel.querySelectorAll('.gbs-tag-pill')).map(pill => pill.dataset.value);
const negativeBlacklistTags = blacklistTags.map(t => `-${t}`);
const areTagsActive = negativeBlacklistTags.every(negTag => tagsAsPills.includes(negTag));
if (areTagsActive) {
blacklistToggleBtn.textContent = 'Remove Blacklist';
} else {
blacklistToggleBtn.textContent = 'Add Blacklist';
}
};
const _determineTagInfo = async (rawTag) => {
const isNegative = rawTag.startsWith('-');
let processedTag = (isNegative ? rawTag.substring(1) : rawTag).trim();
let category = 'general';
let finalTagName = processedTag;
const knownMetadataTags = new Set([
'commentary'
]);
const parts = processedTag.split(':');
if (parts.length > 1 && Object.keys(Config.COLORS_CONSTANTS).includes(parts[0])) {
category = parts[0];
finalTagName = parts.slice(1).join(':');
} else {
category = await API.fetchTagCategory(processedTag);
}
if (isNegative) category = 'excluded';
return {
fullValue: (isNegative ? '-' : '') + finalTagName,
tagName: finalTagName,
category: category
};
};
const _createPillElement = (tagInfo) => {
const pill = document.createElement('span');
pill.className = 'gbs-tag-pill';
pill.textContent = tagInfo.tagName.replace(/_/g, ' ');
pill.dataset.value = tagInfo.fullValue;
pill.dataset.category = tagInfo.category;
pill.style.backgroundColor = Config.COLORS_CONSTANTS[tagInfo.category] || '#777';
const removeBtn = document.createElement('i');
removeBtn.className = 'gbs-remove-tag-btn';
removeBtn.textContent = '×';
removeBtn.onclick = () => {
pill.remove();
syncToOriginalInput();
updateBlacklistButton();
};
pill.appendChild(removeBtn);
return pill;
};
const _sortPills = (container) => {
const order = ['artist', 'character', 'copyright', 'metadata', 'general'];
const pills = Array.from(container.querySelectorAll('.gbs-tag-pill'));
pills.sort((a, b) => {
const catA = a.dataset.category;
const catB = b.dataset.category;
const indexA = order.indexOf(catA);
const indexB = order.indexOf(catB);
return indexA - indexB;
});
container.innerHTML = '';
pills.forEach(pill => container.appendChild(pill));
};
const addPill = async (rawTag) => {
if (!rawTag || rawTag.trim() === '') return;
const tagInfo = await _determineTagInfo(rawTag.trim());
if (modalPanel.querySelector(`.gbs-tag-pill[data-value="${tagInfo.fullValue}"]`)) {
return;
}
const mainPillContainer = modalPanel.querySelector('#gbs-pill-container-main');
const excludedPillContainer = modalPanel.querySelector('#gbs-pill-container-excluded');
const pillElement = _createPillElement(tagInfo);
if (tagInfo.category === 'excluded') {
excludedPillContainer.appendChild(pillElement);
} else {
mainPillContainer.appendChild(pillElement);
_sortPills(mainPillContainer);
}
syncToOriginalInput();
updateBlacklistButton();
};
const toggleBlacklistTags = () => {
const blacklistTags = (Settings.State.BLACKLIST_TAGS || '').trim().split(/\s+/).filter(Boolean);
if (blacklistTags.length === 0) {
alert('Your blacklist is empty. Please add tags in the Suite Settings.');
return;
}
const tagsAsPills = Array.from(modalPanel.querySelectorAll('.gbs-tag-pill'));
const pillValues = tagsAsPills.map(pill => pill.dataset.value);
const negativeBlacklistTags = blacklistTags.map(t => `-${t}`);
const areTagsActive = negativeBlacklistTags.every(negTag => pillValues.includes(negTag));
if (areTagsActive) {
tagsAsPills.forEach(pill => {
if (negativeBlacklistTags.includes(pill.dataset.value)) {
pill.remove();
}
});
} else {
negativeBlacklistTags.forEach(negTag => {
if (!pillValues.includes(negTag)) {
addPill(negTag);
}
});
}
syncToOriginalInput();
updateBlacklistButton();
};
blacklistToggleBtn.addEventListener('click', toggleBlacklistTags);
const quickTagsBtn = modalPanel.querySelector('#gbs-saved-searches-toggle-btn');
const quickTagsPanel = modalPanel.querySelector('#gbs-saved-searches-panel');
const _populateQuickTagsPanel = async (forceRefresh = false) => {
quickTagsPanel.innerHTML = '<span style="color: #ccc; font-style: italic;">Loading saved searches...</span>';
const renderTags = (searches) => {
quickTagsPanel.innerHTML = '';
if (searches && searches.length > 0) {
const fragment = document.createDocumentFragment();
searches.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'gbs-modal-saved-search';
tagEl.textContent = tag.replace(/_/g, ' ');
tagEl.dataset.tag = tag;
tagEl.addEventListener('click', () => {
tag.split(/\s+/).filter(Boolean).forEach(singleTag => addPill(singleTag));
});
fragment.appendChild(tagEl);
});
quickTagsPanel.appendChild(fragment);
} else {
quickTagsPanel.innerHTML = '<span style="color: #A43535;">No saved searches found.</span>';
}
const footer = document.createElement('div');
footer.style.cssText = 'margin-top: 5px; text-align: right;';
const refreshBtn = document.createElement('button');
refreshBtn.innerHTML = 'Update <i class=" fas fa-sync-alt" style="font-size: 0.8em;"></i>';
refreshBtn.style.cssText = 'background: none; border: none; color: #ccc; padding: 0px 10px; cursor: pointer;';
refreshBtn.title = 'Force the list to update. Avoid repeated clicks to prevent overloading the site and triggering temporary blocks.';
refreshBtn.onclick = () => _populateQuickTagsPanel(true);
footer.appendChild(refreshBtn);
quickTagsPanel.appendChild(footer);
};
if (!forceRefresh) {
const cachedSearches = await API.fetchSavedSearches();
renderTags(cachedSearches);
} else {
const freshSearches = await API.fetchSavedSearches(true);
renderTags(freshSearches);
}
};
quickTagsBtn.addEventListener('click', () => {
const isOpen = !quickTagsPanel.classList.contains('hidden');
if (isOpen) {
quickTagsPanel.classList.add('hidden');
quickTagsBtn.classList.remove('active');
} else {
quickTagsPanel.classList.remove('hidden');
quickTagsBtn.classList.add('active');
if (!quickTagsPanel.dataset.loaded) {
_populateQuickTagsPanel(false);
quickTagsPanel.dataset.loaded = 'true';
}
}
});
const modifiersToggleBtn = modalPanel.querySelector('#gbs-modifiers-toggle-btn');
const modifiersPanel = modalPanel.querySelector('#gbs-modifiers-panel');
modifiersToggleBtn.addEventListener('click', () => {
modifiersPanel.classList.toggle('hidden');
modifiersToggleBtn.classList.toggle('active', !modifiersPanel.classList.contains('hidden'));
});
const openModal = () => {
modalPanel.querySelector('#gbs-pill-container-main').innerHTML = '';
modalPanel.querySelector('#gbs-pill-container-excluded').innerHTML = '';
sortSelect.value = '';
ratingSelect.value = '';
scoreOp.value = '>=';
scoreVal.value = '';
const allTags = originalInput.value.trim().split(/\s+/).filter(Boolean);
const regularTags = parseAndSetModifiers(allTags);
regularTags.forEach(tag => addPill(tag));
_sortPills(modalPanel.querySelector('#gbs-pill-container-main'));
modalOverlay.style.opacity = '1';
modalOverlay.style.pointerEvents = 'auto';
tagInput.focus();
updateBlacklistButton();
};
const closeModal = () => { modalOverlay.style.opacity = '0'; modalOverlay.style.pointerEvents = 'none'; };
tagInput.addEventListener('keydown', e => {
if (e.key === ' ') {
e.preventDefault();
tagInput.value += '_';
}
if (e.key === 'Enter') {
e.preventDefault();
const finalTag = tagInput.value.trim().replace(/ /g, '_');
addPill(finalTag);
tagInput.value = '';
suggestionBox.style.display = 'none';
}
});
tagInput.addEventListener('input', () => {
clearTimeout(GlobalState.searchDebounceTimeout);
const term = tagInput.value.trim();
if (term.length < 2) {
suggestionBox.style.display = 'none';
return;
}
GlobalState.searchDebounceTimeout = setTimeout(async () => {
const suggestions = await API.fetchTagSuggestions(term);
suggestionBox.innerHTML = '';
if (suggestions.length > 0) {
suggestionBox.style.display = 'block';
const fragment = document.createDocumentFragment();
suggestions.forEach(sugg => {
const item = document.createElement('div');
item.className = 'gbs-suggestion-item';
const labelSpan = document.createElement('span');
labelSpan.className = 'gbs-suggestion-label';
const nameSpan = document.createElement('span');
let category = sugg.category === 'tag' ? 'general' : sugg.category;
const color = Config.COLORS_CONSTANTS[category] || Config.COLORS_CONSTANTS.general;
nameSpan.style.color = color;
nameSpan.textContent = sugg.label.replace(/_/g, ' ');
const categorySpan = document.createElement('span');
categorySpan.className = 'gbs-suggestion-category';
categorySpan.textContent = `[${sugg.category}]`;
labelSpan.append(nameSpan, categorySpan);
const countSpan = document.createElement('span');
countSpan.className = 'gbs-suggestion-count';
countSpan.style.color = color;
countSpan.textContent = parseInt(sugg.post_count).toLocaleString();
item.append(labelSpan, countSpan);
item.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
let tagToAdd = sugg.value;
if (tagInput.value.trim().startsWith('-')) {
tagToAdd = '-' + tagToAdd;
}
addPill(tagToAdd);
tagInput.value = '';
suggestionBox.style.display = 'none';
tagInput.focus();
};
fragment.appendChild(item);
});
suggestionBox.appendChild(fragment);
} else {
suggestionBox.style.display = 'none';
}
}, 250);
});
closeBtn.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); });
applyBtn.addEventListener('click', () => {
syncToOriginalInput();
closeModal();
originalInput.form.submit();
});
return { openModal };
}
}
};
// =================================================================================
// PEEK: PREVIEWS MODULE
// =================================================================================
const Peek = {
State: {
currentThumb: null,
hideTimeout: null,
showTimeout: null,
dynamicSeekTime: 5,
previewElements: null,
lastHoveredThumb: null,
pendingPreviewRequest: null,
thumbnailListenerCleanups: new Map()
},
init: function() {
this.injectStyles();
document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(this.initializeThumbnailFeatures.bind(this));
const observer = new MutationObserver((mutations) => {
if (GlobalState.previewsTemporarilyDisabled) return;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.matches(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR)) this.initializeThumbnailFeatures(node);
node.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(this.initializeThumbnailFeatures.bind(this));
}
});
mutation.removedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.matches(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR)) this.cleanupThumbnailFeatures(node);
node.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(this.cleanupThumbnailFeatures.bind(this));
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('pagehide', this.cleanupAllFeatures.bind(this));
},
injectStyles: function() {
GM_addStyle(`
.thumbnail-preview img { border-radius: 10px; }
.thumbnail-preview img.gbs-animated-img { box-shadow: 0 0 0 2.5px #C2185B !important; }
#${Config.SELECTORS.PREVIEW_CONTAINER_ID} { position: fixed !important; z-index: 99999 !important; opacity: 0; transform: translate(-50%, -50%) scale(1); pointer-events: none; background-color: #000; border-radius: 10px; box-shadow: 0 0 4px 4px rgba(0,0,0,0.6); overflow: hidden; transition: transform 0.35s ease-out, opacity 0s linear 0.35s; }
#${Config.SELECTORS.PREVIEW_CONTAINER_ID}:focus { outline: none; }
#${Config.SELECTORS.PREVIEW_CONTAINER_ID}.show { opacity: 1; transform: translate(-50%, -50%) scale(${Settings.State.PREVIEW_SCALE_FACTOR}); pointer-events: auto; cursor: pointer; transition: transform 0.35s ease-out, opacity 0.35s ease-out; }
.enhancer-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; transition: opacity 0.2s ease-in-out; background-color: transparent; border: none; outline: none; }
.enhancer-seekbar-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 20px; display: none; justify-content: center; align-items: center; }
#${Config.SELECTORS.PREVIEW_CONTAINER_ID}.video-active .enhancer-seekbar-container { display: flex; }
.enhancer-seekbar { opacity: 0; transition: opacity 0.2s ease-out; pointer-events: none; width: 95%; margin: 0; height: 5.5px; -webkit-appearance: none; appearance: none; background: rgba(255,255,255,0.15); outline: none; border-radius: 10px; border: none; }
.enhancer-seekbar-container:hover .enhancer-seekbar { opacity: 1; pointer-events: auto; }
.enhancer-seekbar::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 8px; height: 8px; background: #fff; cursor: pointer; border-radius: 50%; }
.enhancer-seekbar::-moz-range-thumb { width: 9.5px; height: 9.5px; background: #fff; cursor: pointer; border-radius: 50%; border: none; }
`);
},
initializeThumbnailFeatures: function(grid) {
if (grid.dataset.enhancerInitialized) return;
grid.dataset.enhancerInitialized = 'true';
Logger.log('Initializing Peek features for grid:', grid);
if (!this.State.previewElements) {
this.UI.createPreviewElement();
}
const thumbnailClickHandler = function(event) {
if (Downloader.State.isSelectionModeActive) {
return;
}
event.preventDefault();
event.stopPropagation();
GM_openInTab(this.href, { active: false, setParent: true });
};
// Decorate thumbnails (e.g., for animated gifs)
grid.querySelectorAll(Config.SELECTORS.THUMBNAIL_ANCHOR_SELECTOR).forEach(thumbLink => {
const img = thumbLink.querySelector('img');
if (img) {
const title = img.getAttribute('title') || '';
const isAnimated = title.includes('animated_gif') || title.includes('animated_png');
const isVideo = img.classList.contains('webm');
if (isAnimated && !isVideo) img.classList.add('gbs-animated-img');
}
thumbLink.addEventListener('click', thumbnailClickHandler, { capture: true });
});
// Strip titles to prevent native tooltips from conflicting with Peek
grid.querySelectorAll('[title]').forEach(el => el.removeAttribute('title'));
const stripTitleHandler = e => {
const el = e.target.closest('[title]');
if (el) el.removeAttribute('title');
};
grid.addEventListener('mouseover', stripTitleHandler, true);
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'title') {
mutation.target.removeAttribute('title');
} else if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.hasAttribute?.('title')) node.removeAttribute('title');
node.querySelectorAll?.('[title]').forEach(el => el.removeAttribute('title'));
}
});
}
}
});
observer.observe(grid, { subtree: true, childList: true, attributes: true, attributeFilter: ['title'] });
grid.gbsThumbnailClickHandler = thumbnailClickHandler;
const cleanup = this.UI.setupThumbnailEventListeners(grid);
this.State.thumbnailListenerCleanups.set(grid, {
cleanupFunc: () => cleanup(stripTitleHandler),
observer
});
},
cleanupThumbnailFeatures: function(grid) {
if (!grid.dataset.enhancerInitialized) return;
Logger.log('Cleaning up Peek features for grid:', grid);
if (grid.gbsThumbnailClickHandler) {
grid.querySelectorAll(Config.SELECTORS.THUMBNAIL_ANCHOR_SELECTOR).forEach(thumbLink => {
thumbLink.removeEventListener('click', grid.gbsThumbnailClickHandler, { capture: true });
});
delete grid.gbsThumbnailClickHandler;
}
const cleanupData = this.State.thumbnailListenerCleanups.get(grid);
if (cleanupData) {
cleanupData.cleanupFunc();
cleanupData.observer.disconnect();
this.State.thumbnailListenerCleanups.delete(grid);
}
if (this.State.thumbnailListenerCleanups.size === 0) {
this.UI.destroyPreviewElement();
}
delete grid.dataset.enhancerInitialized;
},
cleanupAllFeatures: function() {
Logger.log('Page is hiding. Cleaning up all residual Peek features.');
for (const grid of this.State.thumbnailListenerCleanups.keys()) {
this.cleanupThumbnailFeatures(grid);
}
},
UI: {
createPreviewElement: function() {
const previewContainer = document.createElement('div');
previewContainer.id = Config.SELECTORS.PREVIEW_CONTAINER_ID;
previewContainer.setAttribute('tabindex', '-1');
previewContainer.innerHTML = `<img class="enhancer-layer low-res-img"><img class="enhancer-layer high-res-img"><video class="enhancer-layer video-layer" playsinline></video><div class="enhancer-error-message"></div><div class="enhancer-seekbar-container"><input type="range" class="enhancer-seekbar" value="0" step="0.1"></div>`;
document.body.appendChild(previewContainer);
const elements = {
previewContainer,
lowResImg: previewContainer.querySelector('.low-res-img'),
highResImg: previewContainer.querySelector('.high-res-img'),
videoLayer: previewContainer.querySelector('.video-layer'),
errorMessage: previewContainer.querySelector('.enhancer-error-message'),
seekBarContainer: previewContainer.querySelector('.enhancer-seekbar-container'),
seekBar: previewContainer.querySelector('.enhancer-seekbar'),
handlers: {}
};
Peek.State.previewElements = elements;
return elements;
},
destroyPreviewElement: function() {
if (!Peek.State.previewElements) return;
Logger.log('Destroying Peek Preview element and its listeners.');
if (Peek.State.pendingPreviewRequest) {
Peek.State.pendingPreviewRequest.abort();
Peek.State.pendingPreviewRequest = null;
}
const { previewContainer, handlers } = Peek.State.previewElements;
previewContainer.removeEventListener('mouseenter', handlers.stopHideTimer);
previewContainer.removeEventListener('mouseleave', handlers.startHideTimer);
previewContainer.removeEventListener('click', handlers.handlePreviewClick);
previewContainer.removeEventListener('keydown', handlers.handlePreviewKeyDown);
Peek.State.previewElements.videoLayer.removeEventListener('timeupdate', handlers.handleVideoTimeUpdate);
Peek.State.previewElements.seekBar.removeEventListener('input', handlers.handleSeekBarInput);
Peek.State.previewElements.seekBarContainer.removeEventListener('click', handlers.handleSeekBarContainerClick);
window.removeEventListener('scroll', handlers.handleInstantHideOnScroll);
if (previewContainer) {
previewContainer.remove();
}
Peek.State.previewElements = null;
},
hidePreview: function() {
if (!Peek.State.previewElements) return;
if (Peek.State.pendingPreviewRequest) {
Peek.State.pendingPreviewRequest.abort();
Peek.State.pendingPreviewRequest = null;
}
const { previewContainer, videoLayer } = Peek.State.previewElements;
videoLayer.pause();
videoLayer.removeAttribute('src');
videoLayer.load();
previewContainer.classList.remove('show', 'video-active', 'error', 'loading');
previewContainer.blur();
Peek.State.currentThumb = null;
},
updatePreviewPositionAndSize: function(thumb, previewContainer) {
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const thumbImg = thumb.querySelector('img');
if (!thumbImg) return;
const thumbRect = thumb.getBoundingClientRect();
const initialWidth = thumbRect.width;
const initialHeight = thumbRect.height;
const finalWidth = initialWidth * Settings.State.PREVIEW_SCALE_FACTOR;
const finalHeight = initialHeight * Settings.State.PREVIEW_SCALE_FACTOR;
const margin = 10;
const rect = thumb.getBoundingClientRect();
let idealTop = rect.top + (rect.height / 2);
let idealLeft = rect.left + (rect.width / 2);
idealLeft = clamp(idealLeft, finalWidth / 2 + margin, window.innerWidth - finalWidth / 2 - margin);
idealTop = clamp(idealTop, finalHeight / 2 + margin, window.innerHeight - finalHeight / 2 - margin);
Object.assign(previewContainer.style, {
width: `${initialWidth}px`,
height: `${initialHeight}px`,
top: `${idealTop}px`,
left: `${idealLeft}px`
});
},
showPreview: async function(thumb) {
if (!Peek.State.previewElements) return;
if (Peek.State.pendingPreviewRequest) {
Peek.State.pendingPreviewRequest.abort();
Peek.State.pendingPreviewRequest = null;
}
Peek.State.currentThumb = thumb;
const { previewContainer, lowResImg, highResImg, videoLayer, errorMessage } = Peek.State.previewElements;
const thumbImg = thumb.querySelector('img');
if (!thumbImg) return;
previewContainer.className = '';
errorMessage.textContent = '';
requestAnimationFrame(() => {
this.updatePreviewPositionAndSize(thumb, previewContainer);
});
lowResImg.src = thumbImg.src;
lowResImg.style.opacity = '1';
highResImg.src = "";
highResImg.style.display = 'none';
highResImg.style.opacity = '0';
videoLayer.style.opacity = '0';
videoLayer.pause();
previewContainer.classList.add('show');
if (Settings.State.PREVIEW_QUALITY === 'low') return;
previewContainer.classList.add('loading');
try {
const postId = Utils.getPostId(thumb.href);
const media = await API.fetchMediaDetails(postId);
if (Peek.State.currentThumb !== thumb) return;
previewContainer.classList.remove('loading');
if (media.type === 'video') {
videoLayer.src = media.url;
videoLayer.loop = Settings.State.PREVIEW_VIDEOS_LOOP;
videoLayer.muted = Settings.State.PREVIEW_VIDEOS_MUTED;
videoLayer.play().catch(() => {});
videoLayer.onloadedmetadata = () => {
if (Peek.State.currentThumb === thumb) {
const seekStep = videoLayer.duration * 0.05;
Peek.State.dynamicSeekTime = Math.max(1, Math.min(seekStep, 10));
videoLayer.style.opacity = '1';
previewContainer.classList.add('video-active');
previewContainer.focus();
}
};
} else {
highResImg.src = media.url;
highResImg.onload = () => {
if (Peek.State.currentThumb === thumb) {
highResImg.style.display = 'block';
highResImg.style.opacity = '1';
}
};
}
} catch (error) {
if (error.message.includes('abort')) return;
if (Peek.State.currentThumb === thumb) {
previewContainer.classList.remove('loading');
previewContainer.classList.add('error');
errorMessage.textContent = error.message;
}
}
},
setupThumbnailEventListeners: function(thumbnailGrid) {
if (!Peek.State.previewElements) return () => {};
const self = Peek; // Reference to the Peek module
const { handlers, previewContainer, videoLayer, seekBarContainer, seekBar } = self.State.previewElements;
handlers.handleInstantHideOnScroll = () => {
if (!self.State.previewElements?.previewContainer || !self.State.currentThumb) { return; }
const { previewContainer } = self.State.previewElements;
previewContainer.style.transition = 'none';
self.UI.hidePreview();
setTimeout(() => {
if (previewContainer) { previewContainer.style.transition = ''; }
}, 50);
};
handlers.startHideTimer = () => {
clearTimeout(self.State.showTimeout);
clearTimeout(self.State.hideTimeout);
self.State.hideTimeout = setTimeout(() => self.UI.hidePreview(), Settings.State.HIDE_DELAY);
};
handlers.stopHideTimer = () => { clearTimeout(self.State.hideTimeout); };
handlers.handleGridMouseOver = (e) => {
const thumb = e.target.closest(Config.SELECTORS.THUMBNAIL_ANCHOR_SELECTOR);
if (thumb) {
handlers.stopHideTimer();
if (self.State.lastHoveredThumb && self.State.lastHoveredThumb !== thumb) {
const oldTitle = self.State.lastHoveredThumb.dataset.originalTitle;
if (oldTitle) self.State.lastHoveredThumb.setAttribute('title', oldTitle);
}
if (thumb.hasAttribute('title')) {
thumb.dataset.originalTitle = thumb.getAttribute('title');
thumb.removeAttribute('title');
}
self.State.lastHoveredThumb = thumb;
if (self.State.currentThumb !== thumb) {
if (self.State.currentThumb) self.UI.hidePreview();
self.State.showTimeout = setTimeout(() => self.UI.showPreview(thumb), Settings.State.SHOW_DELAY);
}
} else if (!previewContainer.matches(':hover')) {
handlers.startHideTimer();
}
};
handlers.handleGridMouseLeave = () => {
if (self.State.lastHoveredThumb) {
const oldTitle = self.State.lastHoveredThumb.dataset.originalTitle;
if (oldTitle) self.State.lastHoveredThumb.setAttribute('title', oldTitle);
self.State.lastHoveredThumb = null;
}
handlers.startHideTimer();
};
handlers.handlePreviewClick = () => { if (self.State.currentThumb?.href) GM_openInTab(self.State.currentThumb.href, { active: false, setParent: true }); };
handlers.handlePreviewKeyDown = e => {
if (self.State.currentThumb && videoLayer.style.opacity === '1') {
const hotkeys = [Settings.State.KEY_PEEK_VIDEO_SEEK_BACK, Settings.State.KEY_PEEK_VIDEO_SEEK_FORWARD, Settings.State.KEY_PEEK_VIDEO_PLAY_PAUSE];
if (hotkeys.includes(e.key)) e.preventDefault();
if (e.key === Settings.State.KEY_PEEK_VIDEO_SEEK_BACK) videoLayer.currentTime -= self.State.dynamicSeekTime;
else if (e.key === Settings.State.KEY_PEEK_VIDEO_SEEK_FORWARD) videoLayer.currentTime += self.State.dynamicSeekTime;
else if (e.key === Settings.State.KEY_PEEK_VIDEO_PLAY_PAUSE) videoLayer.paused ? videoLayer.play() : videoLayer.pause();
}
};
handlers.handleVideoTimeUpdate = () => {
if (videoLayer.duration) {
if (seekBar.max != videoLayer.duration) seekBar.max = videoLayer.duration;
seekBar.value = videoLayer.currentTime;
}
};
handlers.handleSeekBarInput = e => { e.stopPropagation(); videoLayer.currentTime = seekBar.value; };
handlers.handleSeekBarContainerClick = e => e.stopPropagation();
thumbnailGrid.addEventListener('mouseover', handlers.handleGridMouseOver);
thumbnailGrid.addEventListener('mouseleave', handlers.handleGridMouseLeave);
previewContainer.addEventListener('mouseenter', handlers.stopHideTimer);
previewContainer.addEventListener('mouseleave', handlers.startHideTimer);
previewContainer.addEventListener('click', handlers.handlePreviewClick);
previewContainer.addEventListener('keydown', handlers.handlePreviewKeyDown);
videoLayer.addEventListener('timeupdate', handlers.handleVideoTimeUpdate);
seekBar.addEventListener('input', handlers.handleSeekBarInput);
seekBarContainer.addEventListener('click', handlers.handleSeekBarContainerClick);
window.addEventListener('scroll', handlers.handleInstantHideOnScroll, { passive: true });
return (stripTitleHandler) => {
Logger.log(`Cleaning up event listeners for grid:`, thumbnailGrid);
thumbnailGrid.removeEventListener('mouseover', handlers.handleGridMouseOver);
thumbnailGrid.removeEventListener('mouseleave', handlers.handleGridMouseLeave);
thumbnailGrid.removeEventListener('mouseover', stripTitleHandler, true);
};
},
}
};
// =================================================================================
// DOWNLOADER MODULE
// =================================================================================
const Downloader = {
State: {
isSelectionModeActive: false,
isDownloading: false,
isCancelled: false,
downloadQueue: new Set(),
processingQueue: [],
failedDownloads: new Set(),
downloadStatus: new Map()
},
// --- Session Management ---
resetState: function() {
this.State.isDownloading = false;
this.State.isCancelled = false;
this.State.processingQueue = [];
this.State.failedDownloads.clear();
this.State.downloadStatus.clear();
},
// --- Core Download Logic ---
downloadSinglePost: async function(thumbAnchor) {
const pId = Utils.getPostId(thumbAnchor.href);
if (this.State.downloadQueue.has(pId)) return;
this.State.downloadQueue.add(pId);
this.State.downloadStatus.set(pId, 'downloading');
const { progressOverlay, circleFG, circumference } = this.UI.createProgressCircle();
thumbAnchor.appendChild(progressOverlay);
try {
this.UI.updateThumbnailFeedback(thumbAnchor, 'downloading');
await new Promise(r => setTimeout(r, 150));
if (this.State.isCancelled) throw new Error('Cancelled before start');
const media = await API.fetchMediaDetails(pId);
const ext = new URL(media.url).pathname.split('.').pop() || 'jpg';
const filename = `${Settings.State.DOWNLOAD_FOLDER}/post_${pId}.${ext}`;
const { promise } = Utils.makeRequest({
method: "HEAD",
timeout: 15000,
url: media.url,
});
const headResponse = await promise;
const headers = headResponse.responseHeaders;
const totalSizeMatch = headers.match(/content-length:\s*(\d+)/i);
const totalSize = totalSizeMatch ? parseInt(totalSizeMatch[1], 10) : 0;
if (totalSize === 0) throw new Error('Could not determine file size.');
await new Promise((resolve, reject) => {
GM_download({
url: media.url,
name: filename,
onprogress: (progress) => {
if (this.State.isCancelled) {
return reject(new Error('Cancelled during download'));
}
const percentComplete = progress.loaded / totalSize;
const offset = circumference * (1 - percentComplete);
circleFG.style.strokeDashoffset = offset;
},
onload: () => {
if (this.State.isCancelled) return reject(new Error('Cancelled on complete'));
this.UI.updateThumbnailFeedback(thumbAnchor, 'success');
this.State.downloadStatus.set(pId, 'success');
resolve();
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('Timeout'))
});
});
} catch (er) {
if (er.message && er.message.toLowerCase().includes('cancelled')) {
this.UI.updateThumbnailFeedback(thumbAnchor, null);
this.State.downloadStatus.set(pId, 'cancelled');
Logger.log(`Download for post ${pId} cancelled.`);
} else {
this.UI.updateThumbnailFeedback(thumbAnchor, 'error');
this.State.downloadStatus.set(pId, 'error');
Logger.error(`Download failed for post ID ${pId}:`, er);
this.State.failedDownloads.add(pId);
}
throw er;
} finally {
progressOverlay.remove();
this.State.downloadQueue.delete(pId);
}
},
downloadWithRetries: async function(thumb, maxAttempts = 3) {
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await this.downloadSinglePost(thumb);
return;
} catch (err) {
lastError = err;
if (err.message && err.message.toLowerCase().includes('cancelled')) {
throw err;
}
Logger.warn(`Download attempt ${attempt}/${maxAttempts} failed for post ${Utils.getPostId(thumb.href)}.`);
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, 2000 * attempt));
}
}
}
throw lastError;
},
startDownloadAllProcess: async function() {
if (this.State.isDownloading) {
this.State.isCancelled = true;
const btn = document.getElementById('gbs-fab-download-all');
if (btn) {
btn.querySelector('.gbs-fab-text').textContent = 'Cancelling...';
btn.disabled = true;
}
return;
}
const allThumbs = Array.from(document.querySelectorAll(Config.SELECTORS.MEDIA_VIEWER_THUMBNAIL_ANCHOR));
if (allThumbs.length === 0) {
Logger.warn('No items on the page to download.');
return;
}
const blocker = document.createElement('div');
blocker.id = 'gbs-page-blocker';
document.body.appendChild(blocker);
setTimeout(() => { blocker.style.opacity = '1'; }, 10);
this.resetState();
this.State.isDownloading = true;
this.State.isCancelled = false;
document.getElementById('gbs-fab-select').disabled = true;
this.UI.updateDownloadAllButton(true);
this.UI.toggleMenuTriggerCursor(true);
this.UI.showProgressBar(true);
this.UI.updateProgressBar(0, 0, allThumbs.length);
this.UI.resetThumbnailsFeedback();
this.State.processingQueue = [...allThumbs];
Logger.log(`Starting download for ${allThumbs.length} posts.`);
let s = 0, e = 0;
const t = allThumbs.length;
const MAX_CONCURRENCY = 6;
this.UI.updateProgressBar(s, e, t);
const processQueue = async () => {
while (this.State.processingQueue.length > 0 && !this.State.isCancelled) {
const thumb = this.State.processingQueue.shift();
try {
await this.downloadWithRetries(thumb);
s++;
} catch(err) {
if (!err.message?.toLowerCase().includes('cancelled')) {
e++;
}
} finally {
this.UI.updateProgressBar(s, e, t);
await new Promise(r => setTimeout(r, 300 + Math.random() * 500));
}
}
};
const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, this.State.processingQueue.length) }, processQueue);
await Promise.allSettled(workers);
this.finishDownloadProcess(s, e, t);
},
finishDownloadProcess: function(s, e, t) {
const blocker = document.getElementById('gbs-page-blocker');
if (blocker) blocker.remove();
if (this.State.isCancelled) {
Logger.log('Download process cancelled by user.');
} else {
Logger.log(`Download process completed. Success: ${s}, Errors: ${e}, Total: ${t}`);
}
this.UI.showCompletionModal(Array.from(this.State.failedDownloads));
this.UI.toggleActionButtons(true);
document.getElementById('gbs-fab-download-all').disabled = false;
this.UI.updateDownloadAllButton(false);
this.UI.toggleMenuTriggerCursor(false);
this.UI.showProgressBar(false);
this.resetState();
},
UI: {
toggleActionButtons: (enable) => {
document.getElementById('gbs-fab-select').disabled = !enable;
document.getElementById('gbs-fab-download-all').disabled = !enable;
},
showProgressBar: (show) => {
const el = document.getElementById('gbs-progress-bar-container');
if (el) {
el.style.display = show ? 'flex' : 'none';
if (show) Downloader.UI.updateProgressBar(0, 0, 1);
}
},
updateProgressBar: (s, e, t) => {
const p = t > 0 ? ((s + e) / t) * 100 : 0;
const fill = document.querySelector('#gbs-progress-bar-container .gbs-progress-bar-fill');
const text = document.querySelector('#gbs-progress-bar-container .gbs-progress-bar-text');
if (!fill || !text) return;
fill.style.width = `${p}%`;
if (e > 0) {
fill.style.backgroundColor = '#A43535';
text.textContent = `Downloading... (${s}/${t-e}) (Errors: ${e})`;
} else {
fill.style.backgroundColor = '#008450';
text.textContent = `Downloading... (${s}/${t})`;
}
},
updateDownloadAllButton: (isDownloading) => {
const btn = document.getElementById('gbs-fab-download-all');
if (!btn) return;
const btnText = btn.querySelector('.gbs-fab-text');
if (isDownloading) {
btnText.textContent = 'Cancel';
btn.classList.add('gbs-btn-cancel');
} else {
btnText.textContent = 'Download All';
btn.classList.remove('gbs-btn-cancel');
}
},
updateThumbnailFeedback: (thumb, status) => {
if (thumb) {
thumb.classList.remove('gbs-thumb-selected', 'gbs-thumb-success', 'gbs-thumb-error', 'gbs-thumb-downloading');
if (status) thumb.classList.add(`gbs-thumb-${status}`);
}
},
resetThumbnailsFeedback: () => {
document.querySelectorAll('.gbs-thumb-success, .gbs-thumb-error, .gbs-thumb-selected, .gbs-thumb-downloading').forEach(el => {
el.classList.remove('gbs-thumb-success', 'gbs-thumb-error', 'gbs-thumb-selected', 'gbs-thumb-downloading');
});
},
toggleMenuTriggerCursor: (isBlocked) => {
document.getElementById('gbs-downloader-trigger')?.classList.toggle('is-blocked', isBlocked);
},
createProgressCircle: function() {
const svgNS = "http://www.w3.org/2000/svg";
const progressOverlay = document.createElement('div');
progressOverlay.className = 'gbs-progress-overlay';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 50 50');
svg.style.width = '60%'; svg.style.height = '60%';
const circleBG = document.createElementNS(svgNS, 'circle');
circleBG.setAttribute('cx', '25'); circleBG.setAttribute('cy', '25'); circleBG.setAttribute('r', '20'); circleBG.setAttribute('fill', 'transparent'); circleBG.setAttribute('stroke-width', '5');
circleBG.setAttribute('class', 'gbs-progress-circle-bg');
const circleFG = circleBG.cloneNode();
circleFG.setAttribute('class', 'gbs-progress-circle-fg');
const circumference = 2 * Math.PI * 20;
Object.assign(circleFG.style, { strokeDasharray: circumference, strokeDashoffset: circumference });
svg.append(circleBG, circleFG);
progressOverlay.appendChild(svg);
return { progressOverlay, circleFG, circumference };
},
showCompletionModal: function(failedIds) {
if (failedIds.length === 0) {
alert('All downloads on this page completed successfully!');
return;
}
if (document.getElementById('gbs-completion-modal')) return;
const modalHTML = `
<div id="gbs-completion-modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 100000; display: flex; align-items: center; justify-content: center; font-family: sans-serif;">
<div id="gbs-completion-modal" style="background-color: #252525; color: #eee; padding: 20px; border-radius: 10px; width: 90%; max-width: 500px; text-align: center;">
<h3 style="margin-top: 0; border-bottom: 1px solid #555; padding-bottom: 10px;">Download Process Finished</h3>
<p>The following posts could not be downloaded. You can copy the IDs for a manual check:</p>
<textarea readonly style="width: 95%; height: 150px; background: #333; color: #fff; border: 1px solid #555; resize: none; margin-top: 10px;">${failedIds.join(' ')}</textarea>
<button id="gbs-completion-close" style="margin-top: 15px; padding: 8px 16px; background-color: #007BFF; color: white; border: none; border-radius: 10px; cursor: pointer;">Close</button>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
document.getElementById('gbs-completion-close').addEventListener('click', () => {
document.getElementById('gbs-completion-modal-overlay').remove();
});
},
},
toggleSelectionMode: function() {
if (this.State.isDownloading) return;
this.State.isSelectionModeActive = !this.State.isSelectionModeActive;
document.body.classList.toggle('gbs-selection-active', this.State.isSelectionModeActive);
this.UI.toggleMenuTriggerCursor(this.State.isSelectionModeActive);
const selectButton = document.getElementById('gbs-fab-select');
selectButton.querySelector('.gbs-fab-text').textContent = this.State.isSelectionModeActive ? 'Cancel' : 'Download (Select)';
selectButton.classList.toggle('active', this.State.isSelectionModeActive);
GlobalState.previewsTemporarilyDisabled = this.State.isSelectionModeActive;
const grids = document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR);
if (this.State.isSelectionModeActive) {
Logger.log("Selection mode activated. Disabling Peek Previews.");
grids.forEach(grid => Peek.cleanupThumbnailFeatures(grid));
} else {
Logger.log("Selection mode deactivated. Re-enabling Peek Previews.");
grids.forEach(grid => Peek.initializeThumbnailFeatures(grid));
this.UI.resetThumbnailsFeedback();
}
},
handleThumbnailClick: function(event) {
if (!this.State.isSelectionModeActive) return;
const thumbAnchor = event.target.closest(Config.SELECTORS.MEDIA_VIEWER_THUMBNAIL_ANCHOR);
if (!thumbAnchor) return;
event.preventDefault();
event.stopPropagation();
this.downloadSinglePost(thumbAnchor);
},
injectUI: function() {
GM_addStyle(`
#gbs-downloader-wrapper { position: fixed; top: 50%; left: 0; transform: translateY(-50%); z-index: 9998; }
#gbs-downloader-trigger { width: 18px; height: 50px; background-color: #252525; border-radius: 0 10px 10px 0; cursor: pointer; border: 2px solid #333; border-left: none; transition: background-color 0.2s ease; }
#gbs-downloader-wrapper:hover #gbs-downloader-trigger { background-color: #007BFF; }
#gbs-downloader-wrapper.menu-open #gbs-downloader-trigger { background-color: #A43535; }
#gbs-downloader-trigger.is-blocked { cursor: not-allowed; }
#gbs-action-list { position: absolute; top: 50%; left: 100%; transform: translateY(-50%) scale(0.95); margin-left: 15px; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; opacity: 0; transition: opacity 0.2s ease, transform 0.2s ease; pointer-events: none; }
#gbs-downloader-wrapper.menu-open #gbs-action-list { opacity: 1; transform: translateY(-50%) scale(1); pointer-events: auto; }
.gbs-selection-active body, .gbs-selection-active .thumbnail-preview a, .gbs-selection-active .thumbnail-container > span > a { cursor: crosshair !important; }
.thumbnail-preview > a, .thumbnail-container > span > a { display:inline-block; line-height:0; position:relative; transition:transform 0.2s, box-shadow 0.2s; }
.gbs-thumb-downloading { transform:scale(0.95); border-radius: 10px !important; overflow:hidden; outline: 4px solid #EFB700 !important; }
.gbs-thumb-success { border-radius: 10px !important; overflow:hidden; outline: 4px solid #008450 !important; }
.gbs-thumb-error { border-radius: 10px !important; overflow:hidden; outline: 4px solid #B81D13 !important; }
.gbs-thumb-success::after, .gbs-thumb-error::after { content:''; position:absolute; top:0; left:0; width:100%; height:100%; display:flex; align-items:center; justify-content:center; font-size:50px; color:white; text-shadow:0 0 5px black; }
.gbs-thumb-success::after { background-color:rgba(40, 167, 69, 0.7); content:'✔'; }
.gbs-thumb-error::after { background-color:rgba(220, 53, 69, 0.7); content:'✖'; }
#gbs-progress-bar-container { position:fixed; bottom:0; left:0; width:100%; height:25px; background-color:#333; z-index:99999; display:none; align-items:center; border-top:1px solid #555; }
.gbs-progress-bar-fill { background-color:#008450; height:100%; width:0%; transition:width 0.3s ease-in-out; }
.gbs-progress-bar-text { position:absolute; width:100%; text-align:center; color:white; font-weight:bold; text-shadow:1px 1px 1px #000; z-index:10; }
.gbs-progress-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; background-color: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease-in-out; }
.gbs-thumb-downloading .gbs-progress-overlay { opacity: 1; }
.gbs-progress-circle-bg { stroke: rgba(255,255,255,0.2); }
.gbs-progress-circle-fg { stroke: #EFB700; transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 0.1s linear; }
#gbs-page-blocker { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); cursor: progress; z-index: 9997; opacity: 0; transition: opacity 0.3s ease-in-out; }
`);
document.body.insertAdjacentHTML('beforeend', `
<div id="gbs-downloader-wrapper">
<div id="gbs-downloader-trigger"></div>
<div id="gbs-action-list">
<button id="gbs-fab-download-all" class="gbs-fab-action-btn"><span class="gbs-fab-text">Download All</span></button>
<button id="gbs-fab-select" class="gbs-fab-action-btn"><span class="gbs-fab-text">Download (Select)</span></button>
</div>
</div>
<div id="gbs-progress-bar-container" style="display: none;"><div class="gbs-progress-bar-text"></div><div class="gbs-progress-bar-fill"></div></div>
`);
},
setupEventListeners: function() {
const wrapper = document.getElementById('gbs-downloader-wrapper');
const menuTrigger = document.getElementById('gbs-downloader-trigger');
menuTrigger.addEventListener('click', (event) => {
if (this.State.isDownloading || this.State.isSelectionModeActive) return;
event.stopPropagation();
wrapper.classList.toggle('menu-open');
if (!wrapper.classList.contains('menu-open')) {
this.UI.showProgressBar(false);
this.UI.resetThumbnailsFeedback();
}
});
document.getElementById('gbs-fab-download-all').addEventListener('click', () => this.startDownloadAllProcess());
document.getElementById('gbs-fab-select').addEventListener('click', () => this.toggleSelectionMode());
document.body.addEventListener('click', (e) => this.handleThumbnailClick(e), true);
},
toggleVisibility: function(visible) {
const wrapper = document.getElementById('gbs-downloader-wrapper');
if (wrapper) {
wrapper.style.display = visible ? 'block' : 'none';
Logger.log(`Downloader UI visibility set to: ${visible}`);
}
},
init: function() {
const isPoolPage = window.location.search.includes('page=pool');
const isGalleryPage = window.location.search.includes('page=post&s=list');
if (!isPoolPage && !isGalleryPage) return;
this.injectUI();
this.setupEventListeners();
Logger.log('Downloader: Side-drawer UI initialized.');
}
};
// =================================================================================
// ADD TO POOL MODULE
// =================================================================================
const AddToPool = {
State: {
isSelectionModeActive: false,
targetPoolId: null,
},
elements: {},
async init() {
const isPoolPage = window.location.search.includes('page=pool');
const isGalleryPage = window.location.search.includes('page=post&s=list');
if (!isPoolPage && !isGalleryPage) return;
this.injectUI();
this.setupEventListeners();
await this.loadSavedPoolId();
},
async loadSavedPoolId() {
const savedId = await GM.getValue(Config.STORAGE_KEYS.POOL_TARGET_ID, null);
if (savedId) {
this.State.targetPoolId = savedId;
if (this.elements.poolIdInput) {
this.elements.poolIdInput.value = savedId;
}
Logger.log(`[AddToPool] Loaded saved Target Pool ID: ${savedId}`);
}
},
async savePoolId(poolId) {
if (poolId && poolId.match(/^\d+$/)) {
await GM.setValue(Config.STORAGE_KEYS.POOL_TARGET_ID, poolId);
this.State.targetPoolId = poolId;
alert(`Target Pool ID set to: ${poolId}`);
} else {
await GM.setValue(Config.STORAGE_KEYS.POOL_TARGET_ID, null);
this.State.targetPoolId = null;
alert('Invalid or empty Pool ID. Target has been cleared.');
}
},
injectUI() {
GM_addStyle(`
#gbs-pool-wrapper { position: fixed; top: 30%; left: 0; transform: translateY(-50%); z-index: 9998; }
#gbs-pool-trigger { width: 18px; height: 50px; background-color: #252525; border-radius: 0 10px 10px 0; cursor: pointer; border: 2px solid #333; border-left: none; transition: background-color 0.2s ease; }
#gbs-pool-wrapper:hover #gbs-pool-trigger { background-color: #007BFF; }
#gbs-pool-wrapper.menu-open #gbs-pool-trigger { background-color: #A43535; }
#gbs-pool-trigger.is-blocked { cursor: not-allowed; }
#gbs-pool-action-list { position: absolute; top: 50%; left: 100%; transform: translateY(-50%) scale(0.95); margin-left: 15px; gap: 5px; display: flex; flex-direction: column; align-items: flex-start; opacity: 0; transition: opacity 0.2s ease, transform 0.2s ease; pointer-events: none; }
#gbs-pool-wrapper.menu-open #gbs-pool-action-list { opacity: 1; transform: translateY(-50%) scale(1); pointer-events: auto; }
#gbs-pool-input-container { display: flex; gap: 5px; width: 100%; }
#gbs-pool-target-input { flex-grow: 1; width: 0; padding: 8px; background: #333; color: #fff; border-radius: 10px; }
#gbs-pool-target-set-btn { padding: 8px 12px; background-color: #343a40; color: #fff; border: none; border-radius: 10px; font-weight: bold; cursor: pointer; }
#gbs-pool-target-set-btn:hover { background-color: #555; }
body.gbs-pool-select-mode-active .thumbnail-preview img,
body.gbs-pool-select-mode-active .thumbnail-container > span > a { cursor: crosshair !important; }
.thumbnail-container > span > a { display: inline-block; line-height: 0; }
.gbs-pool-add-notification { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 132, 80, 0.8); color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; z-index: 10; border-radius: 10px; pointer-events: none; opacity: 0; transition: opacity 0.3s; }
`);
const wrapper = document.createElement('div');
wrapper.id = 'gbs-pool-wrapper';
wrapper.innerHTML = `
<div id="gbs-pool-trigger"></div>
<div id="gbs-pool-action-list">
<div id="gbs-pool-input-container">
<input type="text" id="gbs-pool-target-input" placeholder="Pool ID" />
<button id="gbs-pool-target-set-btn">Set</button>
</div>
<button id="gbs-pool-select-btn" class="gbs-fab-action-btn">
<span class="gbs-fab-text">Add to Pool (Select)</span>
</button>
</div>
`;
document.body.appendChild(wrapper);
this.elements = {
wrapper,
trigger: wrapper.querySelector('#gbs-pool-trigger'),
selectButton: wrapper.querySelector('#gbs-pool-select-btn'),
poolIdInput: wrapper.querySelector('#gbs-pool-target-input'),
poolIdSetBtn: wrapper.querySelector('#gbs-pool-target-set-btn'),
};
},
setupEventListeners() {
this.elements.trigger.addEventListener('click', (event) => {
if (this.State.isSelectionModeActive) return;
event.stopPropagation();
this.elements.wrapper.classList.toggle('menu-open');
});
this.elements.poolIdSetBtn.addEventListener('click', () => {
const poolId = this.elements.poolIdInput.value.trim();
this.savePoolId(poolId);
});
this.elements.selectButton.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSelectionMode();
});
},
toggleSelectionMode() {
this.State.isSelectionModeActive = !this.State.isSelectionModeActive;
const buttonText = this.elements.selectButton.querySelector('.gbs-fab-text');
this.elements.selectButton.classList.toggle('active', this.State.isSelectionModeActive);
this.elements.trigger.classList.toggle('is-blocked', this.State.isSelectionModeActive);
document.body.classList.toggle('gbs-pool-select-mode-active', this.State.isSelectionModeActive);
if (this.State.isSelectionModeActive) {
buttonText.textContent = 'Cancel';
document.body.addEventListener('click', this.handleThumbnailClick, true);
GlobalState.previewsTemporarilyDisabled = true;
document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(grid => Peek.cleanupThumbnailFeatures(grid));
Logger.log("[AddToPool] Selection mode activated. Disabling Peek Previews.");
} else {
buttonText.textContent = 'Add to Pool (Select)';
document.body.removeEventListener('click', this.handleThumbnailClick, true);
GlobalState.previewsTemporarilyDisabled = false;
document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(grid => Peek.initializeThumbnailFeatures(grid));
Logger.log("[AddToPool] Selection mode deactivated. Re-enabling Peek Previews.");
}
},
handleThumbnailClick: (event) => {
const self = AddToPool;
if (!self.State.isSelectionModeActive) return;
const thumbAnchor = event.target.closest(Config.SELECTORS.MEDIA_VIEWER_THUMBNAIL_ANCHOR);
if (!thumbAnchor) return;
event.preventDefault();
event.stopPropagation();
if (!self.State.targetPoolId) {
alert('Please set a target Pool ID first.');
self.elements.wrapper.classList.add('menu-open');
self.elements.poolIdInput.focus();
return;
}
const postId = Utils.getPostId(thumbAnchor.href);
if (!postId) return;
// If the site's addToPool function is changed or stops using window.prompt, this feature will break.
const script = document.createElement('script');
script.textContent = `(() => {
const originalPrompt = window.prompt;
window.prompt = () => '${self.State.targetPoolId}';
if (typeof addToPoolID === 'function') {
addToPoolID(${postId});
}
window.prompt = originalPrompt;
})();`;
document.body.appendChild(script).remove();
const notif = document.createElement('div');
notif.className = 'gbs-pool-add-notification';
notif.textContent = 'Added!';
thumbAnchor.style.position = 'relative';
thumbAnchor.appendChild(notif);
setTimeout(() => { notif.style.opacity = '1'; }, 10);
setTimeout(() => {
notif.style.opacity = '0';
setTimeout(() => notif.remove(), 300);
}, 1500);
},
toggleVisibility: function(visible) {
const wrapper = document.getElementById('gbs-pool-wrapper');
if (wrapper) {
wrapper.style.display = visible ? 'block' : 'none';
Logger.log(`AddToPool UI visibility set to: ${visible}`);
}
},
};
// =================================================================================
// MEDIA VIEWER MODULE
// =================================================================================
const MediaViewer = {
_boundKeyDownHandler: null,
_isNavigating: false,
elements: {},
State: {
isLargeViewActive: false,
currentImageIndex: -1,
largeMediaElements: [],
thumbnailAnchors: [],
inactivityTimer: null,
_boundResetInactivityTimer: null,
_scrollHandler: null,
_isThrottled: false,
},
init() {
const isPoolPage = window.location.search.includes('page=pool');
const isGalleryPage = window.location.search.includes('page=post&s=list');
if (!isPoolPage && !isGalleryPage) return;
this.State.thumbnailAnchors = Array.from(document.querySelectorAll(Config.SELECTORS.MEDIA_VIEWER_THUMBNAIL_ANCHOR));
this.injectUI();
this.setupEventListeners();
},
injectUI() {
GM_addStyle(`
body.gbs-viewer-mode-active #container { display: block !important; }
body.gbs-viewer-mode-active section.aside { display: none !important; }
body.gbs-hide-viewer-cursor, body.gbs-hide-viewer-cursor * { cursor: none !important; }
.gbs-large-view-active.thumbnail-container > div[style*="text-align: center"] { display: none !important; }
.gbs-large-view-active.thumbnail-container { display: block !important; }
.gbs-large-view-active.thumbnail-container > .thumbnail-preview { width: 100vw !important; max-width: none !important; height: 100vh !important; display: flex !important; justify-content: center !important; align-items: center !important; padding: 0 !important; margin: 0 !important; }
.gbs-large-view-active.thumbnail-container > span { height: 100vh; display: flex; justify-content: center; align-items: center; }
.gbs-large-view-active.thumbnail-container a { pointer-events: none; }
.gbs-large-view-media { max-width: 93vw !important; max-height: 98vh !important; object-fit: contain; pointer-events: auto; border-radius: 0px !important; }
.gbs-viewer-nav-item { color: #fff; background-color: rgba(37, 37, 37, 0.8); padding: 5px; border-radius: 10px; border: 2px solid rgba(51, 51, 51, 0.5); width: 45px; height: 40px; display: flex; align-items: center; justify-content: center; box-sizing: border-box !important; transition: background-color 0.2s, border-color 0.2s ease; }
.gbs-viewer-nav-btn { font-size: 24px; cursor: pointer; }
.gbs-viewer-nav-btn:hover { background-color: #333; border-color: #333; }
#gbs-viewer-nav-counter { font-size: 14px; font-weight: bold; user-select: none; height: 30px;}
#gbs-viewer-nav-container { position: fixed; top: 80%; left: 4px; z-index: 99999; display: none; flex-direction: column; gap: 5px; }
#gbs-viewer-nav-container.visible { display: flex; }
#gbs-viewer-top-controls { position: fixed; top: 4%; left: 4px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; }
#gbs-viewer-btn, #gbs-viewer-show-info-btn, #gbs-viewer-open-post-btn { color: #fff !important; position: static; transform: none; width: 45px; height: 45px; padding: 5px; background-color: rgba(37, 37, 37, 0.8); backdrop-filter: blur(5px); border: 2px solid rgba(51, 51, 51, 0.5); border-radius: 10px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; }
#gbs-viewer-show-info-btn, #gbs-viewer-open-post-btn { display: none; }
#gbs-viewer-btn:hover, #gbs-viewer-show-info-btn:hover, #gbs-viewer-open-post-btn:hover { background-color: #006FFA; border-color: #006FFA; }
#gbs-viewer-btn.active { background-color: rgba(37, 37, 37, 0.8); border-color: rgba(51, 51, 51, 0.5); }
#gbs-viewer-btn.active:hover { background-color: #A43535; border-color: #A43535; }
#gbs-viewer-show-info-btn.active { color: #006FFA !important; border-color: #006FFA; }
#gbs-viewer-show-info-btn:hover { color: white; }
#gbs-viewer-top-controls, #gbs-viewer-nav-container { transition: opacity 0.3s ease-in-out; }
#gbs-viewer-info-sidebar { position: fixed !important; top: 0; right: -280px; width: 250px; height: 100vh; background-color: #1f1f1f; border-left: 2px solid #333; z-index: 99999; padding: 2px; box-sizing: border-box; overflow-y: auto; transition: right 0.3s ease-in-out; }
#gbs-viewer-info-sidebar.visible { right: 0; }
#gbs-viewer-info-sidebar .tag-list { position: absolute !important; width: 90%; box-sizing: border-box; word-wrap: break-word; padding: 0px; border: 0 !important; }
#gbs-viewer-info-sidebar .tag-type-artist a { color: #AA0000 !important; }
#gbs-viewer-info-sidebar .tag-type-character a { color: #00AA00 !important; }
#gbs-viewer-info-sidebar .tag-type-copyright a { color: #AA00AA !important; }
#gbs-viewer-info-sidebar .tag-type-metadata a { color: #FF8800 !important; }
#gbs-viewer-info-sidebar .tag-type-general a { color: white !important; }
#gbs-viewer-info-sidebar #tag-list li { padding: 0 !important; margin: 0 !important; width: 100% !important; line-height: 23px !important; }
.gbs-ui-hidden { opacity: 0; pointer-events: none; }
.gbs-custom-action-btn { display: block;background-color: rgba(0, 111, 250, 0.5); color: white !important; padding: 8px; margin-bottom: 10px; margin-top: 10px; border-radius: 10px; text-align: center; font-weight: bold; font-size: 22px; transition: background-color 0.2s ease; }
.gbs-custom-action-btn:hover { background-color: #006FFA; color: white !important; }
.gbs-action-buttons-container { display: grid !important; grid-template-columns: 1fr 3fr; gap: 10px; }
.gbs-thumb-placeholder { display: inline-flex; align-items: center; justify-content: center; }
.gbs-thumb-placeholder .gbs-thumb-loader { color: white; font-size: 2em; animation: gbs-spin 1.2s linear infinite; }
@keyframes gbs-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
#gbs-loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.9); z-index: 100000; opacity: 1; transition: opacity 0.2s ease-out; pointer-events: none; }
@media (max-width: 850px) {
#gbs-viewer-top-controls { top: auto; bottom: 20px; left: 15px; flex-direction: row; gap: 2px; }
#gbs-viewer-nav-container {top: auto; bottom: 20px; left: auto; right: 15px; flex-direction: row; gap: 2px; }
#gbs-viewer-nav-counter { height: 45px; }
.gbs-viewer-nav-item { height: 45px; }
#gbs-viewer-nav-up i { transform: rotate(-90deg); }
#gbs-viewer-nav-down i { transform: rotate(-90deg); }
body.gbs-viewer-mode-active .thumbnail-container { column-count: 1 !important; }
#gbs-viewer-info-sidebar { width: 215px; }
}
`);
const topControlsContainer = document.createElement('div');
topControlsContainer.id = 'gbs-viewer-top-controls';
const viewerButton = document.createElement('button');
viewerButton.id = 'gbs-viewer-btn';
viewerButton.title = 'Open/Close Media Viewer';
viewerButton.innerHTML = '<i class="fas fa-images"></i>';
const showInfoBtn = document.createElement('button');
showInfoBtn.id = 'gbs-viewer-show-info-btn';
showInfoBtn.title = 'Show Post Info';
showInfoBtn.innerHTML = '<i class="fas fa-info"></i>';
const openPostBtn = document.createElement('button');
openPostBtn.id = 'gbs-viewer-open-post-btn';
openPostBtn.title = 'Open Post in New Tab';
openPostBtn.innerHTML = '<i class="fas fa-external-link-alt"></i>';
topControlsContainer.append(viewerButton, showInfoBtn, openPostBtn);
document.body.appendChild(topControlsContainer);
const navContainer = document.createElement('div');
navContainer.id = 'gbs-viewer-nav-container';
navContainer.innerHTML = `
<button class="gbs-viewer-nav-item gbs-viewer-nav-btn" id="gbs-viewer-nav-up" title="Previous Image"><i class="fas fa-angle-up"></i></button>
<div class="gbs-viewer-nav-item" id="gbs-viewer-nav-counter">0</div>
<button class="gbs-viewer-nav-item gbs-viewer-nav-btn" id="gbs-viewer-nav-down" title="Next Image"><i class="fas fa-angle-down"></i></button>
`;
document.body.appendChild(navContainer);
const infoSidebar = document.createElement('div');
infoSidebar.id = 'gbs-viewer-info-sidebar';
document.body.appendChild(infoSidebar);
this.elements = {
viewerButton,
topControlsContainer,
navContainer,
navUpButton: navContainer.querySelector('#gbs-viewer-nav-up'),
navCounter: navContainer.querySelector('#gbs-viewer-nav-counter'),
navDownButton: navContainer.querySelector('#gbs-viewer-nav-down'),
showInfoButton: showInfoBtn,
openPostButton: openPostBtn,
infoSidebar,
};
},
setupEventListeners() {
this.elements.viewerButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleLargeThumbnails();
});
this.elements.navUpButton.addEventListener('click', () => this.navigateToImage(-1));
this.elements.navDownButton.addEventListener('click', () => this.navigateToImage(1));
this.elements.showInfoButton.addEventListener('click', () => this.toggleInfoSidebar());
this.elements.openPostButton.addEventListener('click', () => {
if (this.State.currentImageIndex >= 0 && this.State.largeMediaElements.length > 0) {
const currentMedia = this.State.largeMediaElements[this.State.currentImageIndex];
const anchor = currentMedia.closest('a');
if (anchor && anchor.href) {
GM_openInTab(anchor.href, { active: false });
}
}
});
},
async toggleLargeThumbnails() {
this.State.isLargeViewActive = !this.State.isLargeViewActive;
this.elements.viewerButton.classList.toggle('active', this.State.isLargeViewActive);
const container = document.querySelector('.thumbnail-container');
if (container) container.classList.toggle('gbs-large-view-active', this.State.isLargeViewActive);
if (this.State.isLargeViewActive) {
await this.activateLargeView();
} else {
this.deactivateLargeView();
}
},
async activateLargeView() {
const loadingOverlay = document.createElement('div');
loadingOverlay.id = 'gbs-loading-overlay';
document.body.appendChild(loadingOverlay);
try {
GlobalState.previewsTemporarilyDisabled = true;
if (typeof Peek !== 'undefined' && Settings.State.ENABLE_PEEK_PREVIEWS) {
Logger.log("[MediaViewer] Activated. Disabling Peek Previews to prevent conflicts.");
document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(grid => {
if (grid.dataset.enhancerInitialized) Peek.cleanupThumbnailFeatures(grid);
});
}
document.body.classList.add('gbs-viewer-mode-active');
if (typeof Downloader !== 'undefined') Downloader.toggleVisibility(false);
if (typeof AddToPool !== 'undefined') AddToPool.toggleVisibility(false);
this.elements.viewerButton.innerHTML = '<i class="fas fa-times"></i>';
document.body.style.overflow = 'hidden';
const firstThumbImg = this.State.thumbnailAnchors.length > 0 ? this.State.thumbnailAnchors[0].querySelector('img') : null;
const placeholderDims = firstThumbImg ? { w: firstThumbImg.getBoundingClientRect().width, h: firstThumbImg.getBoundingClientRect().height } : { w: 150, h: 150 };
this.State.thumbnailAnchors.forEach(anchor => {
const originalThumb = anchor.querySelector('img');
if (originalThumb) originalThumb.style.display = 'none';
const placeholder = document.createElement('div');
placeholder.className = 'gbs-thumb-placeholder';
placeholder.style.width = `${placeholderDims.w}px`;
placeholder.style.height = `${placeholderDims.h}px`;
placeholder.innerHTML = `<i class="fas fa-spinner gbs-thumb-loader"></i>`;
placeholder.dataset.loaded = 'false';
anchor.appendChild(placeholder);
});
if (this.State.thumbnailAnchors.length > 0) {
const firstAnchor = this.State.thumbnailAnchors[0];
const placeholder = firstAnchor.querySelector('.gbs-thumb-placeholder');
if (placeholder) {
placeholder.dataset.loaded = 'loading';
await this.loadAndReplaceMedia(firstAnchor, placeholderDims);
}
}
if (!this._boundKeyDownHandler) {
this._boundKeyDownHandler = this.handleNavKeyDown.bind(this);
}
document.addEventListener('keydown', this._boundKeyDownHandler);
this.State.currentImageIndex = -1;
this.elements.navContainer.classList.add('visible');
this.setupInactivityListeners();
this.navigateToImage(1);
this.State._scrollHandler = this._lazyLoadCheck.bind(this);
window.addEventListener('scroll', this.State._scrollHandler);
window.addEventListener('resize', this.State._scrollHandler);
this._lazyLoadCheck();
} finally {
loadingOverlay.style.opacity = '0';
loadingOverlay.addEventListener('transitionend', () => {
loadingOverlay.remove();
}, { once: true });
}
},
deactivateLargeView() {
document.getElementById('gbs-loading-overlay')?.remove();
GlobalState.previewsTemporarilyDisabled = false;
if (typeof Peek !== 'undefined' && Settings.State.ENABLE_PEEK_PREVIEWS) {
Logger.log("[MediaViewer] Deactivated. Re-enabling Peek Previews.");
document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(grid => {
Peek.initializeThumbnailFeatures(grid);
});
}
document.body.classList.remove('gbs-viewer-mode-active');
this.cleanupInactivityListeners();
if (typeof Downloader !== 'undefined') Downloader.toggleVisibility(true);
if (typeof AddToPool !== 'undefined') AddToPool.toggleVisibility(true);
if (this.State._scrollHandler) {
window.removeEventListener('scroll', this.State._scrollHandler);
window.removeEventListener('resize', this.State._scrollHandler);
this.State._scrollHandler = null;
}
this.elements.viewerButton.innerHTML = '<i class="fas fa-images"></i>';
this.elements.showInfoButton.style.display = 'none';
this.elements.openPostButton.style.display = 'none';
this.elements.infoSidebar.classList.remove('visible');
this.elements.showInfoButton.classList.remove('active');
document.body.style.overflow = '';
const thumbnailContainer = document.querySelector('.thumbnail-container');
if (thumbnailContainer) thumbnailContainer.classList.remove('gbs-large-view-active');
if (this._boundKeyDownHandler) document.removeEventListener('keydown', this._boundKeyDownHandler);
this.elements.navContainer.classList.remove('visible');
this.State.thumbnailAnchors.forEach(anchor => {
anchor.querySelector('.gbs-large-view-media, .gbs-thumb-placeholder')?.remove();
const originalThumb = anchor.querySelector('img');
if (originalThumb) originalThumb.style.display = '';
});
this.State.largeMediaElements = [];
},
resetInactivityTimer() {
if (!this.State.isLargeViewActive) return;
this.elements.topControlsContainer?.classList.remove('gbs-ui-hidden');
this.elements.navContainer?.classList.remove('gbs-ui-hidden');
document.body.classList.remove('gbs-hide-viewer-cursor');
clearTimeout(this.State.inactivityTimer);
this.State.inactivityTimer = setTimeout(() => {
const isHoveringControls = this.elements.topControlsContainer?.matches(':hover') || this.elements.navContainer?.matches(':hover');
if (!isHoveringControls) {
this.elements.topControlsContainer?.classList.add('gbs-ui-hidden');
this.elements.navContainer?.classList.add('gbs-ui-hidden');
document.body.classList.add('gbs-hide-viewer-cursor');
}
}, 3000);
},
setupInactivityListeners() {
this.State._boundResetInactivityTimer = this.resetInactivityTimer.bind(this);
document.addEventListener('mousemove', this.State._boundResetInactivityTimer);
this.resetInactivityTimer();
},
cleanupInactivityListeners() {
if (this.State._boundResetInactivityTimer) {
document.removeEventListener('mousemove', this.State._boundResetInactivityTimer);
this.State._boundResetInactivityTimer = null;
}
clearTimeout(this.State.inactivityTimer);
this.State.inactivityTimer = null;
this.elements.topControlsContainer?.classList.remove('gbs-ui-hidden');
this.elements.navContainer?.classList.remove('gbs-ui-hidden');
document.body.classList.remove('gbs-hide-viewer-cursor');
},
_lazyLoadCheck() {
if (this.State._isThrottled) return;
this.State._isThrottled = true;
setTimeout(() => {
const placeholders = document.querySelectorAll('.gbs-thumb-placeholder[data-loaded="false"]');
const viewportHeight = window.innerHeight;
placeholders.forEach(placeholder => {
const rect = placeholder.getBoundingClientRect();
if (rect.top < viewportHeight * 6.5) {
placeholder.dataset.loaded = 'loading';
const anchor = placeholder.closest('a');
if (anchor) {
const dims = { w: rect.width, h: rect.height };
this.loadAndReplaceMedia(anchor, dims);
}
}
});
this.State._isThrottled = false;
}, 200);
},
loadAndReplaceMedia(anchor, dims) {
return new Promise(async (resolve) => {
const placeholder = anchor.querySelector('.gbs-thumb-placeholder');
if (!placeholder) return resolve();
const postId = Utils.getPostId(anchor.href);
if (!postId) {
placeholder.remove();
return resolve();
}
try {
const media = await API.fetchMediaDetailsFromHTML(anchor.href);
const mediaElement = media.type === 'video' ? document.createElement('video') : document.createElement('img');
mediaElement.addEventListener('click', (e) => e.preventDefault());
mediaElement.src = media.url;
mediaElement.className = 'gbs-large-view-media';
if (media.type === 'video') {
mediaElement.controls = true;
mediaElement.loop = true;
mediaElement.muted = false;
}
const loadEvent = media.type === 'video' ? 'loadeddata' : 'load';
mediaElement.addEventListener(loadEvent, async () => {
if (media.type === 'image') {
try {
await mediaElement.decode();
} catch (e) {
Logger.warn('Image decode failed, but proceeding:', e);
}
}
placeholder.replaceWith(mediaElement);
this.State.largeMediaElements = Array.from(document.querySelectorAll('.gbs-large-view-media'));
resolve();
}, { once: true });
mediaElement.addEventListener('error', () => {
placeholder.remove();
resolve();
}, { once: true });
} catch (error) {
Logger.error(`Failed to load media for post ${postId}:`, error);
placeholder.remove();
resolve();
}
});
},
navigateToImage(direction) {
if (this._isNavigating) return;
this._isNavigating = true;
if (this.State.currentImageIndex === this.State.thumbnailAnchors.length - 1 && direction > 0) {
window.scrollBy({ top: 400, behavior: 'smooth' });
this._isNavigating = false;
return;
}
if (this.State.currentImageIndex >= 0) {
const prevAnchor = this.State.thumbnailAnchors[this.State.currentImageIndex];
const prevMedia = prevAnchor?.querySelector('video.gbs-large-view-media');
if (prevMedia) prevMedia.pause();
}
if (this.State.thumbnailAnchors.length === 0) {
this._isNavigating = false;
return;
}
const newIndex = Math.max(0, Math.min(this.State.thumbnailAnchors.length - 1, this.State.currentImageIndex + direction));
if (newIndex !== this.State.currentImageIndex) {
this.State.currentImageIndex = newIndex;
const targetAnchor = this.State.thumbnailAnchors[this.State.currentImageIndex];
if (targetAnchor) {
targetAnchor.scrollIntoView({ behavior: 'auto', block: 'center' });
}
if (this.elements.infoSidebar.classList.contains('visible')) {
this.fetchAndDisplayInfo();
}
}
this.updateNavCounter();
this._lazyLoadCheck();
setTimeout(() => { this._isNavigating = false; }, 50);
},
handleNavKeyDown(event) {
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
const viewerHotkeys = [
Settings.State.KEY_VIEWER_PREV_IMAGE,
Settings.State.KEY_VIEWER_NEXT_IMAGE,
Settings.State.KEY_VIEWER_TOGGLE_INFO
];
if (!viewerHotkeys.includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
if (event.key === Settings.State.KEY_VIEWER_PREV_IMAGE) {
this.navigateToImage(-1);
} else if (event.key === Settings.State.KEY_VIEWER_NEXT_IMAGE) {
this.navigateToImage(1);
} else if (event.key === Settings.State.KEY_VIEWER_TOGGLE_INFO) {
this.toggleInfoSidebar();
}
},
updateNavCounter() {
if (!this.elements.navCounter) return;
const currentImageNum = Math.max(0, this.State.currentImageIndex + 1);
this.elements.navCounter.textContent = currentImageNum;
const shouldShow = this.State.isLargeViewActive && currentImageNum > 0;
this.elements.showInfoButton.style.display = shouldShow ? 'flex' : 'none';
this.elements.openPostButton.style.display = shouldShow ? 'flex' : 'none';
},
toggleInfoSidebar() {
const isVisible = this.elements.infoSidebar.classList.toggle('visible');
this.elements.showInfoButton.classList.toggle('active', isVisible);
if (isVisible) this.fetchAndDisplayInfo();
},
async fetchAndDisplayInfo() {
const sidebar = this.elements.infoSidebar;
if (this.State.currentImageIndex < 0 || this.State.currentImageIndex >= this.State.thumbnailAnchors.length) return;
const anchor = this.State.thumbnailAnchors[this.State.currentImageIndex];
if (!anchor || !anchor.href) return;
const postId = Utils.getPostId(anchor.href);
if (sidebar.dataset.currentPostId === postId) return;
sidebar.innerHTML = '<p> Loading info...</p>';
try {
const { promise } = Utils.makeRequest({ method: "GET", url: anchor.href });
const response = await promise;
const doc = new DOMParser().parseFromString(response.responseText, "text/html");
const tagListElement = doc.querySelector('#tag-list');
if (tagListElement) {
const actionButtonsContainer = document.createElement('li');
actionButtonsContainer.className = 'gbs-action-buttons-container';
const favoritesLi = Array.from(tagListElement.querySelectorAll('li a')).find(a => a.textContent.includes('Add to favorites'))?.closest('li');
if (favoritesLi) {
const favoritesLink = favoritesLi.querySelector('a');
favoritesLink.innerHTML = '<i class="fas fa-star"></i>';
favoritesLink.className = 'gbs-custom-action-btn';
favoritesLink.addEventListener('click', function() {
this.style.backgroundColor = '#daa520';
this.style.pointerEvents = 'none';
}, { once: true });
actionButtonsContainer.appendChild(favoritesLink);
favoritesLi.remove();
}
const poolLi = Array.from(tagListElement.querySelectorAll('li a')).find(a => a.textContent.includes('Add to Pool'))?.closest('li');
if (poolLi) {
const poolLink = poolLi.querySelector('a');
poolLink.removeAttribute('onclick');
poolLink.innerHTML = 'Add to Pool';
poolLink.className = 'gbs-custom-action-btn';
poolLink.style.fontSize = '15px';
poolLink.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!AddToPool.State.targetPoolId) {
alert('Target Pool ID not set. Please configure it on the main page.');
return;
}
const script = document.createElement('script');
script.textContent = `(() => {
const originalPrompt = window.prompt;
window.prompt = () => '${AddToPool.State.targetPoolId}';
if (typeof addToPoolID === 'function') {
addToPoolID(${postId});
}
window.prompt = originalPrompt;
})();`;
document.body.appendChild(script).remove();
this.textContent = 'Added to Pool';
this.style.backgroundColor = '#daa520';
this.style.pointerEvents = 'none';
}, { once: true });
actionButtonsContainer.appendChild(poolLink);
poolLi.remove();
}
if (actionButtonsContainer.hasChildNodes()) {
tagListElement.prepend(actionButtonsContainer);
}
tagListElement.querySelectorAll('a').forEach(a => {
if (!a.classList.contains('gbs-custom-action-btn')) {
a.href = new URL(a.getAttribute('href'), 'https://gelbooru.com/').href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
}
});
sidebar.innerHTML = '';
sidebar.appendChild(tagListElement);
sidebar.dataset.currentPostId = postId;
} else {
throw new Error("Could not find '#tag-list' in the post page.");
}
} catch (error) {
Logger.error("Failed to fetch or parse post info:", error);
sidebar.innerHTML = `<p style="color: #A43535;"> Error loading info.</p>`;
}
},
};
// =================================================================================
// MAIN APPLICATION ORCHESTRATOR
// =================================================================================
const App = {
addGlobalStyles: function() {
let customCss = '';
if (window.location.href.includes('page=favorites')) {
customCss += `html, body { background-color: #1F1F1F !important; }`;
}
if (Settings.State.HIDE_PAGE_SCROLLBARS) {
customCss += `html, body { scrollbar-width: none !important;} html::-webkit-scrollbar, body::-webkit-scrollbar { display: none !important; }`;
}
if (GlobalState.pageType === 'post') {
GM_addStyle(`
main #image, main video#gelcomVideoPlayer { width: 100vw !important; margin: auto !important; object-fit: contain !important; }
@media (max-width: 850px) {
#scrollebox { flex-direction: column-reverse; align-items: center; gap: 50px; }
}
`);
}
if (!document.querySelector('link[href*="font-awesome"]')) {
const faLink = document.createElement('link');
faLink.rel = 'stylesheet';
faLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css';
document.head.appendChild(faLink);
}
if (customCss.trim() !== '') {
GM_addStyle(customCss);
}
GM_addStyle(`
.gbs-fab-action-btn {min-width: 168px; justify-content: center; background-color: #343a40; color: white; font-weight: bold; border: none; border-radius: 10px; padding: 10px 15px; cursor: pointer; display: flex; align-items: center; box-shadow: 0 2px 5px rgba(0,0,0,0.2); white-space: nowrap; transition: background-color 0.2s; }
.gbs-fab-action-btn:hover { background-color: #007BFF; }
.gbs-fab-action-btn:disabled { opacity: 0.6; cursor: not-allowed; background-color: #343a40; }
.gbs-fab-action-btn.active { background-color: #A43535; color: white !important; }
.gbs-fab-action-btn.active:hover { background-color: #e74c3c; }
`);
},
collapseStatsByDefault: function() {
const toggleButton = document.querySelector('.profileToggleStats a');
if (toggleButton) {
toggleButton.click();
}
},
movePostActions: function() {
const scrollbox = document.querySelector('#scrollebox');
const mediaElement = document.querySelector('#image, #gelcomVideoPlayer');
if (scrollbox && mediaElement) {
mediaElement.after(scrollbox);
scrollbox.style.marginTop = '10px';
scrollbox.style.paddingLeft = '10px';
scrollbox.style.paddingRight = '10px';
scrollbox.style.fontSize = '12px';
scrollbox.style.display = 'flex';
scrollbox.style.justifyContent = 'space-between';
scrollbox.style.alignItems = 'center';
}
const allH2s = document.querySelectorAll('h2');
const commentsHeader = Array.from(allH2s).find(h2 => h2.textContent.trim() === 'User Comments:');
const adLinkAnchor = document.querySelector('div > a[rel="nofollow"][target="_blank"]');
const adContainer = adLinkAnchor ? adLinkAnchor.parentElement : null;
if (adContainer && commentsHeader) {
commentsHeader.parentElement.insertBefore(adContainer, commentsHeader);
}
const mobileAdInnerDiv = document.querySelector('div[data-cl-spot]');
const mobileAdContainer = mobileAdInnerDiv ? mobileAdInnerDiv.closest('center') : null;
if (mobileAdContainer && commentsHeader) {
commentsHeader.parentElement.insertBefore(mobileAdContainer, commentsHeader);
}
const originalContentNodes = Array.from(scrollbox.childNodes);
scrollbox.innerHTML = '';
const leftContainer = document.createElement('span');
const rightContainer = document.createElement('span');
scrollbox.appendChild(leftContainer);
scrollbox.appendChild(rightContainer);
const prevLink = document.querySelector('a[onclick="navigatePrev();"]');
const nextLink = document.querySelector('a[onclick="navigateNext();"]');
if (prevLink && nextLink) {
const navContainer = prevLink.closest('div.alert');
nextLink.removeAttribute('style');
leftContainer.append('(', prevLink, ' / ', nextLink, ')');
if (navContainer) navContainer.remove();
}
originalContentNodes.forEach(node => {
rightContainer.appendChild(node);
});
const allLinksInList = document.querySelectorAll('#tag-list li a');
const addToPoolLink = Array.from(allLinksInList).find(link => link.textContent.trim() === 'Add to Pool');
if (addToPoolLink) {
const parentLi = addToPoolLink.closest('li');
rightContainer.append(' | ', addToPoolLink);
if (parentLi) parentLi.remove();
}
const resizeLinkContainer = document.querySelector('#resize-link');
if (resizeLinkContainer) {
resizeLinkContainer.remove();
}
},
scrollToActionBar: function() {
const scrollbox = document.querySelector('#scrollebox');
if (scrollbox) {
setTimeout(() => {
scrollbox.style.scrollMarginBottom = '10px';
scrollbox.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
},
setupScrollTrigger: function() {
if (document.visibilityState === 'visible') {
this.scrollToActionBar();
} else {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.scrollToActionBar();
}
}, { once: true });
}
},
adjustMediaHeight: function() {
const mediaElement = document.querySelector('#image, #gelcomVideoPlayer');
const scrollbox = document.querySelector('#scrollebox');
const topBar = document.querySelector('.searchArea');
if (mediaElement && scrollbox && topBar) {
const topBarHeight = topBar.offsetHeight;
const scrollboxHeight = scrollbox.offsetHeight;
const reservedSpace = topBarHeight + scrollboxHeight;
mediaElement.style.maxHeight = `calc(100vh - ${reservedSpace}px)`;
}
},
setupGalleryHotkeys: function() {
document.addEventListener('keydown', e => {
const activeEl = document.activeElement;
if (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl === document.getElementById(Config.SELECTORS.PREVIEW_CONTAINER_ID) || activeEl.closest(`#${Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID}`)) return;
const currentPageElement = document.querySelector(Config.SELECTORS.PAGINATION_CURRENT_SELECTOR);
if (!currentPageElement) return;
let targetLink = null;
if (e.key === Settings.State.KEY_GALLERY_NEXT_PAGE) {
targetLink = currentPageElement.nextElementSibling;
} else if (e.key === Settings.State.KEY_GALLERY_PREV_PAGE) {
targetLink = currentPageElement.previousElementSibling;
}
if (targetLink?.tagName === 'A') {
window.location.href = targetLink.href;
}
});
},
async init() {
await Settings.load();
const isGalleryPage = !!document.querySelector(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR);
const contentElement = document.querySelector(Config.SELECTORS.IMAGE_SELECTOR) || document.querySelector(Config.SELECTORS.VIDEO_PLAYER_SELECTOR);
const isPostPage = contentElement && !contentElement.closest('.thumbnail-preview');
const isAccountPage = window.location.search.includes('page=account');
if (isPostPage) {
GlobalState.pageType = 'post';
} else if (isGalleryPage) {
GlobalState.pageType = 'gallery';
} else if (isAccountPage) {
GlobalState.pageType = 'account';
}
this.addGlobalStyles();
GM_registerMenuCommand('Suite Settings', () => Settings.UI.openModal());
if (GlobalState.pageType === 'account') {
this.collapseStatsByDefault();
}
if (GlobalState.pageType === 'gallery') {
this.setupGalleryHotkeys();
if (Settings.State.ENABLE_ADVANCED_SEARCH) {
AdvancedSearch.init();
}
if (Settings.State.ENABLE_PEEK_PREVIEWS) {
Peek.init();
}
if (Settings.State.ENABLE_ADD_TO_POOL) {
await AddToPool.init();
}
if (Settings.State.ENABLE_DOWNLOADER) {
Downloader.init();
}
}
if (GlobalState.pageType === 'post') {
this.movePostActions();
this.adjustMediaHeight();
this.setupScrollTrigger();
}
MediaViewer.init();
Logger.log("Gelbooru Suite initialized.");
}
};
// =================================================================================
// SCRIPT ENTRY POINT
// =================================================================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', App.init.bind(App));
} else {
App.init();
}
})();