您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances Gelbooru with a categorized pop-up search, thumbnail previews, a lightbox, an immersive deck viewer, and more.
当前为
// ==UserScript== // @name Gelbooru Suite // @namespace GelbooruEnhancer // @version 087.1 // @description Enhances Gelbooru with a categorized pop-up search, thumbnail previews, a lightbox, an immersive deck viewer, and more. // @author Testador (Refactored by Gemini) // @match *://gelbooru.com/* // @icon https://gelbooru.com/layout/gelbooru-logo.svg // @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== (function() { 'use strict'; // ================================================================================= // CONFIGURATION AND CONSTANTS MODULE // ================================================================================= const Config = { DEFAULT_SETTINGS: { DEBUG: true, // --- Global Toggles --- ENABLE_ADVANCED_SEARCH: true, ENABLE_PEEK_PREVIEWS: true, ENABLE_PEEK_LIGHTBOX: true, ENABLE_DECK_VIEWER: true, BLACKLIST_TAGS: '', QUICK_TAGS_LIST: '', // --- Peek: Previews & Lightbox --- PREVIEW_QUALITY: 'high', AUTOPLAY_LIGHTBOX_VIDEOS: true, HIDE_PAGE_SCROLLBARS: true, PREVIEW_VIDEOS_MUTED: true, PREVIEW_VIDEOS_LOOP: true, ZOOM_SCALE_FACTOR: 2.3, SHOW_DELAY: 350, HIDE_DELAY: 175, LIGHTBOX_BG_COLOR: 'rgba(0, 0, 0, 0.85)', // --- Deck: Immersive Viewer --- DECK_PRELOAD_AHEAD: 5, DECK_PRELOAD_BEHIND: 5, ZOOM_SENSITIVITY: 1.1, // --- Hotkeys --- KEY_GALLERY_NEXT_PAGE: 'ArrowRight', KEY_GALLERY_PREV_PAGE: 'ArrowLeft', KEY_PEEK_VIDEO_PLAY_PAUSE: ' ', KEY_PEEK_VIDEO_SEEK_FORWARD: 'ArrowRight', KEY_PEEK_VIDEO_SEEK_BACK: 'ArrowLeft', KEY_DECK_NEXT_MEDIA: 'ArrowRight', KEY_DECK_PREV_MEDIA: 'ArrowLeft', KEY_DECK_JUMP_BOX: 'Enter', KEY_DECK_TOGGLE_BOOKMARK: 'b', KEY_DECK_CLOSE_PANELS: 'Escape', // --- API --- API_BASE_URL: 'https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1', API_AUTOCOMPLETE_URL: 'https://gelbooru.com/index.php?page=autocomplete2&type=tag_query&limit=10', 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', DECK_VIEWER_ID: 'gbs-deck-viewer-overlay', galleryNavSubmenu: '.navSubmenu', postTagListItem: '#tag-list li[class*="tag-type-"]', postTagLink: 'a[href*="&tags="]', }, STORAGE_KEYS: { SUITE_SETTINGS: 'gelbooruSuite_settings', DECK_BOOKMARKS: 'gelbooruSuite_bookmarks' }, DECK_CONSTANTS: { POSTS_PER_PAGE: 42, TAG_COLORS: { 'artist': '#AA0000', 'character': '#00AA00', 'copyright': '#AA00AA', 'metadata': '#FF8800', 'general': '#337ab7', 'excluded': '#999999' }, CURSOR_INACTIVITY_TIME: 3000, HISTORY_LENGTH: 5, CSS_CLASSES: { HIDDEN: 'hidden', ACTIVE: 'active', VISIBLE: 'visible', LOADING: 'loading', INVALID: 'invalid', GRAB: 'grab', GRABBING: 'grabbing', POINTER: 'pointer' }, } }; // ================================================================================= // APPLICATION STATE MODULE // ================================================================================= const State = { settings: {}, currentThumb: null, hideTimeout: null, showTimeout: null, dynamicSeekTime: 5, previewElements: null, lastHoveredThumb: null, pendingPreviewRequest: null, galleryData: { posts: [], startIndex: 0, nextPageUrl: null, prevPageUrl: null, baseUrl: '', lastPageNum: 1 }, currentIndex: 0, isLoadingNextPage: false, navigationHistory: [], bookmarks: [], searchDebounceTimeout: null, }; // ================================================================================= // UTILITY, API, ZOOM Modules and LOGGER Module // ================================================================================= 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 (!State.settings.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.DEFAULT_SETTINGS.API_AUTOCOMPLETE_URL}&limit=1&term=${encodedTerm}`; try { const { promise } = Utils.makeRequest({ method: "GET", url }); const response = await promise; const data = JSON.parse(response.responseText); if (data && data[0] && data[0].value === tagName) { return data[0].category === 'tag' ? 'general' : data[0].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.DEFAULT_SETTINGS.API_AUTOCOMPLETE_URL}&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 (State.settings.API_KEY && State.settings.USER_ID) { const apiUrl = `${Config.DEFAULT_SETTINGS.API_BASE_URL}&id=${postId}&user_id=${State.settings.USER_ID}&api_key=${State.settings.API_KEY}`; try { request = Utils.makeRequest({ method: "GET", url: apiUrl }); State.pendingPreviewRequest = request.xhr; const response = await request.promise; if (response.status === 401 || response.status === 403) { UI.openSettingsModal("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 { 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 }); 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 { State.pendingPreviewRequest = null; } }, 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.`); } }, fetchPage: async function(isPrev) { const urlToFetch = isPrev ? State.galleryData.prevPageUrl : State.galleryData.nextPageUrl; if (State.isLoadingNextPage || !urlToFetch) return null; State.isLoadingNextPage = true; try { const url = new URL(urlToFetch); const tags = url.searchParams.get('tags') || ''; const pid = parseInt(url.searchParams.get('pid'), 10) || 0; const pageNum = Math.floor(pid / Config.DECK_CONSTANTS.POSTS_PER_PAGE); let apiUrl = Config.DEFAULT_SETTINGS.API_BASE_URL; if (State.settings.API_KEY && State.settings.USER_ID) { apiUrl += `&api_key=${State.settings.API_KEY}&user_id=${State.settings.USER_ID}`; } apiUrl += `&tags=${encodeURIComponent(tags)}&pid=${pageNum}&limit=${Config.DECK_CONSTANTS.POSTS_PER_PAGE}`; const { promise } = Utils.makeRequest({ method: "GET", url: apiUrl }); const response = await promise; let data; try { data = JSON.parse(response.responseText); } catch (e) { Logger.error("Gelbooru Suite: Failed to parse JSON response.", e); Logger.error("Raw response text:", response.responseText); UI.showLoadingMessage("Failed to load page. You may have reached the API limit."); if (isPrev) { State.galleryData.prevPageUrl = null; } else { State.galleryData.nextPageUrl = null; } return null; } if (!data.post || data.post.length === 0) { if (document.getElementById('viewer-main')) UI.showLoadingMessage('No more results found.'); State.galleryData.nextPageUrl = null; return null; } const newPosts = data.post.map(p => { const isVideo = p.image && ['mp4', 'webm'].includes(p.image.split('.').pop()); return { postUrl: `https://gelbooru.com/index.php?page=post&s=view&id=${p.id}`, thumbUrl: p.preview_url, mediaUrl: p.file_url, type: isVideo ? 'video' : 'image', }; }); State.galleryData.posts = newPosts; State.galleryData.baseUrl = urlToFetch; const postCount = parseInt(data['@attributes'].count, 10); const lastPageNum = Math.floor(postCount / Config.DECK_CONSTANTS.POSTS_PER_PAGE); State.galleryData.lastPageNum = lastPageNum + 1; if (pageNum < lastPageNum) { const nextUrl = new URL(urlToFetch); nextUrl.searchParams.set('pid', ((pageNum + 1) * Config.DECK_CONSTANTS.POSTS_PER_PAGE).toString()); State.galleryData.nextPageUrl = nextUrl.href; } else { State.galleryData.nextPageUrl = null; } if (pageNum > 0) { const prevUrl = new URL(urlToFetch); prevUrl.searchParams.set('pid', ((pageNum - 1) * Config.DECK_CONSTANTS.POSTS_PER_PAGE).toString()); State.galleryData.prevPageUrl = prevUrl.href; } else { State.galleryData.prevPageUrl = null; } return isPrev ? newPosts.length - 1 : 0; } catch (error) { Logger.error("API Fetch Error:", error); if (document.getElementById('viewer-main')) UI.showLoadingMessage(error.message); return null; } finally { State.isLoadingNextPage = false; } }, 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; } }; const Zoom = { enable: function(mediaElement, options = {}) { const { clickAction = 'open_tab' } = options; let scale = 1, isPanning = false, pointX = 0, pointY = 0, start = { x: 0, y: 0 }; const setTransform = () => { mediaElement.style.transform = `translate(${pointX}px, ${pointY}px) scale(${scale})`; mediaElement.style.transformOrigin = '0 0'; }; const onWheel = (e) => { e.preventDefault(); const rect = mediaElement.getBoundingClientRect(); const xs = (e.clientX - rect.left) / scale; const ys = (e.clientY - rect.top) / scale; const delta = (e.deltaY > 0) ? (1 / State.settings.ZOOM_SENSITIVITY) : State.settings.ZOOM_SENSITIVITY; const oldScale = scale; scale *= delta; if (scale < 1) { scale = 1; pointX = 0; pointY = 0; } else if (scale > 20) { scale = 20; } else { pointX += xs * oldScale - xs * scale; pointY += ys * oldScale - ys * scale; } mediaElement.style.cursor = (scale > 1) ? Config.DECK_CONSTANTS.CSS_CLASSES.GRAB : Config.DECK_CONSTANTS.CSS_CLASSES.POINTER; setTransform(); }; const onMouseDown = (e) => { if (scale > 1 && e.button === 0) { e.preventDefault(); isPanning = true; start = { x: e.clientX - pointX, y: e.clientY - pointY }; mediaElement.style.cursor = Config.DECK_CONSTANTS.CSS_CLASSES.GRABBING; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); } }; const onMouseMove = (e) => { if (isPanning) { e.preventDefault(); pointX = e.clientX - start.x; pointY = e.clientY - start.y; setTransform(); } }; const onMouseUp = () => { isPanning = false; mediaElement.style.cursor = Config.DECK_CONSTANTS.CSS_CLASSES.GRAB; window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; const toggleZoom = (e) => { if (scale > 1) { scale = 1; pointX = 0; pointY = 0; mediaElement.style.cursor = Config.DECK_CONSTANTS.CSS_CLASSES.POINTER; } else { scale = State.settings.ZOOM_SCALE_FACTOR || 2.3; const rect = mediaElement.getBoundingClientRect(); const xs = (e.clientX - rect.left); const ys = (e.clientY - rect.top); pointX = xs - xs * scale; pointY = ys - ys * scale; mediaElement.style.cursor = Config.DECK_CONSTANTS.CSS_CLASSES.GRAB; } setTransform(); }; const onClick = (e) => { if (scale <= 1) { e.preventDefault(); if (clickAction === 'toggle_zoom') { toggleZoom(e); } else if (clickAction === 'open_tab') { GM_openInTab(State.galleryData.posts[State.currentIndex].postUrl, { active: false, setParent: true }); } } }; const onDblClick = (e) => { e.preventDefault(); toggleZoom(e); } mediaElement.addEventListener('wheel', onWheel); mediaElement.addEventListener('mousedown', onMouseDown); mediaElement.addEventListener('dblclick', onDblClick); if (clickAction !== 'none') { mediaElement.addEventListener('click', onClick); } const destroy = () => { mediaElement.removeEventListener('wheel', onWheel); mediaElement.removeEventListener('mousedown', onMouseDown); mediaElement.removeEventListener('dblclick', onDblClick); if (clickAction !== 'none') { mediaElement.removeEventListener('click', onClick); } window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; return { destroy }; } }; const Settings = { load: async function() { const savedSettings = await GM.getValue(Config.STORAGE_KEYS.SUITE_SETTINGS, {}); State.settings = { ...Config.DEFAULT_SETTINGS, ...savedSettings }; }, save: async function() { const getHotkey = (id) => Utils.formatHotkeyForStorage(document.getElementById(id).value); const newSettings = { ENABLE_ADVANCED_SEARCH: document.getElementById('setting-advanced-search').checked, ENABLE_PEEK_PREVIEWS: document.getElementById('setting-peek-previews').checked, ENABLE_PEEK_LIGHTBOX: document.getElementById('setting-peek-lightbox').checked, ENABLE_DECK_VIEWER: document.getElementById('setting-deck-viewer').checked, BLACKLIST_TAGS: document.getElementById('setting-blacklist-tags').value.trim(), QUICK_TAGS_LIST: document.getElementById('setting-quick-tags-list').value.trim(), PREVIEW_QUALITY: document.getElementById('setting-preview-quality').value, AUTOPLAY_LIGHTBOX_VIDEOS: document.getElementById('setting-autoplay-lightbox').checked, HIDE_PAGE_SCROLLBARS: document.getElementById('setting-hide-scrollbars').checked, PREVIEW_VIDEOS_MUTED: document.getElementById('setting-preview-muted').checked, PREVIEW_VIDEOS_LOOP: document.getElementById('setting-preview-loop').checked, ZOOM_SCALE_FACTOR: parseFloat(document.getElementById('setting-zoom').value) || Config.DEFAULT_SETTINGS.ZOOM_SCALE_FACTOR, LIGHTBOX_BG_COLOR: document.getElementById('setting-lightbox-bg').value.trim() || Config.DEFAULT_SETTINGS.LIGHTBOX_BG_COLOR, DECK_PRELOAD_AHEAD: parseInt(document.getElementById('setting-deck-preload-ahead').value, 10), DECK_PRELOAD_BEHIND: parseInt(document.getElementById('setting-deck-preload-behind').value, 10), ZOOM_SENSITIVITY: parseFloat(document.getElementById('setting-zoom-sens').value), 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_DECK_NEXT_MEDIA: getHotkey('setting-key-deck-next') || Config.DEFAULT_SETTINGS.KEY_DECK_NEXT_MEDIA, KEY_DECK_PREV_MEDIA: getHotkey('setting-key-deck-prev') || Config.DEFAULT_SETTINGS.KEY_DECK_PREV_MEDIA, KEY_DECK_JUMP_BOX: getHotkey('setting-key-deck-jump') || Config.DEFAULT_SETTINGS.KEY_DECK_JUMP_BOX, KEY_DECK_TOGGLE_BOOKMARK: getHotkey('setting-key-deck-bookmark') || Config.DEFAULT_SETTINGS.KEY_DECK_TOGGLE_BOOKMARK, KEY_DECK_CLOSE_PANELS: getHotkey('setting-key-deck-close') || Config.DEFAULT_SETTINGS.KEY_DECK_CLOSE_PANELS, API_BASE_URL: document.getElementById('setting-api-url').value.trim() || Config.DEFAULT_SETTINGS.API_BASE_URL, API_KEY: document.getElementById('setting-api-key').value.trim(), USER_ID: document.getElementById('setting-user-id').value.trim(), }; await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, newSettings); State.settings = newSettings; UI.closeSettingsModal(); 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 = { ...State.settings, API_KEY: '', USER_ID: '' }; await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, newSettings); State.settings = newSettings; window.location.reload(); } }, export: function() { const jsonString = JSON.stringify(State.settings, null, 2); const infoEl = document.getElementById('enhancer-manage-info'); navigator.clipboard.writeText(jsonString).then(() => { infoEl.textContent = 'Settings copied to clipboard!'; infoEl.style.color = '#98c379'; infoEl.style.display = 'block'; setTimeout(() => { infoEl.style.display = 'none'; }, 3000); }).catch(err => { Logger.error('Failed to copy settings: ', err); infoEl.textContent = 'Failed to copy. See console for details.'; infoEl.style.color = '#e06c75'; infoEl.style.display = 'block'; }); }, import: async function() { const textarea = document.getElementById('enhancer-import-area'); const jsonString = textarea.value; const infoEl = document.getElementById('enhancer-manage-info'); infoEl.style.display = 'block'; if (!jsonString.trim()) { infoEl.textContent = 'Import field is empty.'; infoEl.style.color = '#e06c75'; return; } try { const importData = JSON.parse(jsonString); if (importData && typeof importData === 'object') { await GM.setValue(Config.STORAGE_KEYS.SUITE_SETTINGS, importData); infoEl.textContent = 'Settings imported! Page will reload...'; infoEl.style.color = '#98c379'; setTimeout(() => window.location.reload(), 1500); } else { throw new Error("Invalid or incomplete settings format."); } } catch (error) { infoEl.textContent = `Import failed: ${error.message}`; infoEl.style.color = '#e06c75'; Logger.error('Import error:', error); } }, testCredentials: async function() { const apiKey = document.getElementById('setting-api-key').value.trim(); const userId = document.getElementById('setting-user-id').value.trim(); const statusEl = document.getElementById('enhancer-api-status'); if (!apiKey || !userId) { statusEl.textContent = 'API Key and User ID are required.'; statusEl.style.color = '#e5c07b'; statusEl.style.display = 'block'; return; } statusEl.textContent = 'Testing...'; statusEl.style.color = '#61afef'; statusEl.style.display = 'block'; const testUrl = `${Config.DEFAULT_SETTINGS.API_BASE_URL}&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) { statusEl.textContent = 'Success! Credentials are valid.'; statusEl.style.color = '#98c379'; } else { throw new Error(`Authentication failed (Status: ${response.status})`); } } catch (error) { Logger.error('API connection test failed:', error); statusEl.textContent = `Error: ${error.message}. Please check your credentials.`; statusEl.style.color = '#e06c75'; } } }; const UI = { mainContainer: null, thumbContainer: null, tagsList: null, inactivityTimer: null, escapeHandler: null, createAdvancedSearchModal: 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; const CATEGORIES = ['artist', 'character', 'copyright', 'metadata', 'general', 'excluded']; let categorySectionsHTML = ''; CATEGORIES.forEach(cat => { const title = cat.charAt(0).toUpperCase() + cat.slice(1); categorySectionsHTML += ` <div class="gbs-category-section" id="gbs-section-${cat}"> <h4 class="gbs-category-title" style="border-color: ${Config.DECK_CONSTANTS.TAG_COLORS[cat]}">${title}</h4> <div class="gbs-pill-container" id="gbs-pill-container-${cat}"></div> </div> `; }); modalPanel.innerHTML = ` <h3 class="gbs-search-title">Advanced Tag Editor</h3> <div class="gbs-input-wrapper"> <input type="text" id="gbs-tag-input" placeholder="Type a tag, use '-' to exclude. Press space for '_'..."> <i class="fas fa-tag" id="gbs-quick-tags-toggle-btn" title="Show Quick Tags"></i> <div class="gbs-suggestion-container" id="gbs-main-suggestion-container"></div> </div> <div id="gbs-quick-tags-panel" class="hidden"></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">Apply & 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 syncToOriginalInput = () => { const allPills = modalPanel.querySelectorAll('.gbs-tag-pill'); const tags = Array.from(allPills).map(pill => pill.dataset.value); originalInput.value = tags.join(' '); }; const updateBlacklistButton = () => { const blacklistTags = (State.settings.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 Tags'; } else { blacklistToggleBtn.textContent = 'Add Blacklist Tags'; } }; const _determineTagInfo = async (rawTag) => { const isNegative = rawTag.startsWith('-'); let processedTag = (isNegative ? rawTag.substring(1) : rawTag).trim(); let category = 'general'; let finalTagName = processedTag; const parts = processedTag.split(':'); if (parts.length > 1 && Object.keys(Config.DECK_CONSTANTS.TAG_COLORS).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.style.backgroundColor = Config.DECK_CONSTANTS.TAG_COLORS[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 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 pillContainer = modalPanel.querySelector(`#gbs-pill-container-${tagInfo.category}`); if (!pillContainer) { Logger.warn(`Could not find pill container for category: ${tagInfo.category}`); return; } const pillElement = _createPillElement(tagInfo); pillContainer.appendChild(pillElement); syncToOriginalInput(); updateBlacklistButton(); }; const toggleBlacklistTags = () => { const blacklistTags = (State.settings.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-quick-tags-toggle-btn'); const quickTagsPanel = modalPanel.querySelector('#gbs-quick-tags-panel'); const quickTags = State.settings.QUICK_TAGS_LIST.split(',').map(t => t.trim()).filter(t => t.length > 0); if (quickTags.length > 0) { const fragment = document.createDocumentFragment(); quickTags.forEach(tag => { const tagEl = document.createElement('span'); tagEl.className = 'gbs-modal-quick-tag'; tagEl.textContent = tag.replace(/_/g, ' '); tagEl.dataset.tag = tag; tagEl.addEventListener('click', () => { addPill(tag); }); fragment.appendChild(tagEl); }); quickTagsPanel.appendChild(fragment); } else { quickTagsBtn.style.display = 'none'; } quickTagsBtn.addEventListener('click', () => { quickTagsPanel.classList.toggle('hidden'); quickTagsBtn.classList.toggle('active', !quickTagsPanel.classList.contains('hidden')); }); const openModal = () => { modalPanel.querySelectorAll('.gbs-pill-container').forEach(c => { c.innerHTML = '' }); const currentTags = originalInput.value.trim().split(/\s+/).filter(Boolean); currentTags.forEach(tag => addPill(tag)); modalOverlay.style.display = 'flex'; tagInput.focus(); updateBlacklistButton(); }; const closeModal = () => { modalOverlay.style.display = '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(State.searchDebounceTimeout); const term = tagInput.value.trim(); if (term.length < 2) { suggestionBox.style.display = 'none'; return; } State.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.DECK_CONSTANTS.TAG_COLORS[category] || Config.DECK_CONSTANTS.TAG_COLORS.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(); addPill(sugg.value); tagInput.value = ''; suggestionBox.style.display = 'none'; tagInput.focus(); }; fragment.appendChild(item); }); suggestionBox.appendChild(fragment); } else { suggestionBox.style.display = 'none'; } }, 250); }); tagInput.addEventListener('blur', () => setTimeout(() => { suggestionBox.style.display = 'none'; }, 150)); closeBtn.addEventListener('click', closeModal); modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); }); applyBtn.addEventListener('click', () => { syncToOriginalInput(); closeModal(); originalInput.form.submit(); }); return { openModal }; }, _getGeneralSettingsHTML: function() { return ` <div class="settings-tab-pane active" data-tab="general"> <div class="setting-item"><label for="setting-advanced-search">Enable Advanced Search Pop-up</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 Peek 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-peek-lightbox">Enable Peek Lightbox</label><label class="toggle-switch"><input type="checkbox" id="setting-peek-lightbox"><span class="toggle-slider"></span></label></div> <div class="setting-item"><label for="setting-deck-viewer">Enable Deck Viewer</label><label class="toggle-switch"><input type="checkbox" id="setting-deck-viewer"><span class="toggle-slider"></span></label></div> <div class="setting-item"><label for="setting-zoom-sens">Zoom Sensitivity:</label><input type="range" id="setting-zoom-sens" min="1.05" max="1.5" step="0.05"><span id="zoom-sens-value">1.1</span></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-quick-tags-list">Quick Tags List (comma-separated)</label> <p class="setting-note" style="text-align: left; margin: 5px 0 10px 0;">Tags for the 'Quick Tag' button in the Advanced Search modal.</p> <textarea id="setting-quick-tags-list" rows="3" placeholder="Example: 1boy, rating:explicit, dark_skin ..."></textarea> </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 here will be used by the 'Toggle Blacklist' button in the Advanced Search 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 (Full media)</option><option value="low">Low (Thumbnail only)</option></select></div> <div class="setting-item"><label for="setting-zoom">Preview Zoom Scale Factor</label><input type="number" id="setting-zoom" 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> <hr class="setting-divider"> <div class="setting-item"><label for="setting-autoplay-lightbox">Autoplay Lightbox Videos</label><label class="toggle-switch"><input type="checkbox" id="setting-autoplay-lightbox"><span class="toggle-slider"></span></label></div> <div class="setting-item"><label for="setting-lightbox-bg">Lightbox Background</label><input type="text" id="setting-lightbox-bg" placeholder="e.g., rgba(0,0,0,0.85)"></div> </div>`; }, _getDeckSettingsHTML: function() { return ` <div class="settings-tab-pane" data-tab="deck"> <div class="setting-item"><label for="setting-deck-preload-ahead">Preload ahead:</label><input type="number" id="setting-deck-preload-ahead" min="0" max="20"></div> <div class="setting-item"><label for="setting-deck-preload-behind">Preload behind:</label><input type="number" id="setting-deck-preload-behind" min="0" max="20"></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">Gallery: 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">Gallery: Next Page</label><input type="text" id="setting-key-gallery-next" class="hotkey-input" readonly></div> <h4 class="setting-subheader">Peek Preview Video Hotkeys</h4> <div class="setting-item"><label for="setting-key-peek-vid-play">Video: 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">Video: 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">Video: Seek Forward</label><input type="text" id="setting-key-peek-vid-fwd" class="hotkey-input" readonly></div> <h4 class="setting-subheader">Deck Viewer Hotkeys</h4> <div class="setting-item"><label for="setting-key-deck-prev">Previous Media</label><input type="text" id="setting-key-deck-prev" class="hotkey-input" readonly></div> <div class="setting-item"><label for="setting-key-deck-next">Next Media</label><input type="text" id="setting-key-deck-next" class="hotkey-input" readonly></div> <div class="setting-item"><label for="setting-key-deck-jump">Open Jump Box</label><input type="text" id="setting-key-deck-jump" class="hotkey-input" readonly></div> <div class="setting-item"><label for="setting-key-deck-bookmark">Toggle Bookmark</label><input type="text" id="setting-key-deck-bookmark" class="hotkey-input" readonly></div> <div class="setting-item"><label for="setting-key-deck-close">Close Panels / Viewer</label><input type="text" id="setting-key-deck-close" 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 (you must be logged in).</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="setting-item"><label for="setting-api-url">API Base URL</label><input type="text" id="setting-api-url"></div> <div class="api-test-container"> <button id="enhancer-test-creds">Test Connection</button> <span id="enhancer-api-status" style="display:none;"></span> </div> <p class="enhancer-error-message" id="enhancer-auth-error" style="display:none; text-align: center;"></p> <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 to Clipboard</button> <button id="enhancer-import-settings">Import and Reload</button> </div> <textarea id="enhancer-import-area" rows="3" placeholder="Paste your exported settings string here and click Import..."></textarea> <p class="enhancer-info-message" id="enhancer-manage-info" style="display:none;"></p> </div> </div>`; }, createSettingsModal: 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; font-family: sans-serif; } #${Config.SELECTORS.SETTINGS_MODAL_ID} { background-color: #252525; color: #eee !important; border: 1px solid #666; border-radius: 8px; z-index: 101; padding: 20px; box-shadow: 0 5px 25px #000a; width: 90%; max-width: 550px; } #${Config.SELECTORS.SETTINGS_MODAL_ID} * { color: #eee !important; } #${Config.SELECTORS.SETTINGS_MODAL_ID} h2 { text-align: center; margin-top: 0; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #555; } #${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; padding-right: 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: 53vh; overflow-y: auto; padding: 0 10px; box-sizing: border-box; } #${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; padding: 5px; background: #333; border: 1px solid #555; color: #fff !important; border-radius: 4px; resize: vertical; min-height: 60px; margin-top: 10px; } #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item label { color: #eee !important; margin-right: 10px; flex-shrink: 0; } #${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: 150px; padding: 5px; background: #333; border: 1px solid #555; color: #fff !important; border-radius: 4px; } #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-item input[type=range] { flex-grow: 1; } #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-note { font-size: 12px; color: #999 !important; text-align: center; margin-top: 5px; margin-bottom: 15px; } #${Config.SELECTORS.SETTINGS_MODAL_ID} .setting-note strong { color: #e5c07b !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 16px; border: 1px solid #666; background-color: #444; color: #fff !important; border-radius: 5px; 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: 4px; 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: #c0392b; } #${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: #61afef; color: #252525 !important; font-weight: bold; } #${Config.SELECTORS.SETTINGS_MODAL_ID} #enhancer-api-status { font-size: 0.9em; font-weight: bold; } #${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: 22px; } .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">Peek Previews</button> <button class="settings-tab-btn" data-tab="deck">Deck Viewer</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._getDeckSettingsHTML()} ${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 & Reload</button> <button id="enhancer-save-settings">Save & Reload</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); document.getElementById('enhancer-clear-creds').addEventListener('click', Settings.clearCredentials); document.getElementById('enhancer-close-settings').addEventListener('click', this.closeSettingsModal); document.getElementById('enhancer-export-settings').addEventListener('click', Settings.export); document.getElementById('enhancer-import-settings').addEventListener('click', Settings.import); document.getElementById('enhancer-test-creds').addEventListener('click', Settings.testCredentials); document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`).addEventListener('click', (e) => { if (e.target.id === `${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`) this.closeSettingsModal(); }); 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'); } }); const zoomSens = document.getElementById('setting-zoom-sens'); if (zoomSens) zoomSens.addEventListener('input', (e) => { document.getElementById('zoom-sens-value').textContent = parseFloat(e.target.value).toFixed(2); }); 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 = State.settings[settingKey] || Config.DEFAULT_SETTINGS[settingKey]; input.value = Utils.formatHotkeyForDisplay(defaultValue); } input.removeEventListener('keydown', handleKeyDown); }; input.addEventListener('focus', handleFocus); input.addEventListener('blur', handleBlur); }); }, openSettingsModal: function(authError = '') { if (!document.getElementById(Config.SELECTORS.SETTINGS_MODAL_ID)) { this.createSettingsModal(); } document.getElementById('setting-advanced-search').checked = State.settings.ENABLE_ADVANCED_SEARCH; document.getElementById('setting-peek-previews').checked = State.settings.ENABLE_PEEK_PREVIEWS; document.getElementById('setting-peek-lightbox').checked = State.settings.ENABLE_PEEK_LIGHTBOX; document.getElementById('setting-deck-viewer').checked = State.settings.ENABLE_DECK_VIEWER; document.getElementById('setting-blacklist-tags').value = State.settings.BLACKLIST_TAGS; document.getElementById('setting-quick-tags-list').value = State.settings.QUICK_TAGS_LIST; document.getElementById('setting-preview-quality').value = State.settings.PREVIEW_QUALITY; document.getElementById('setting-autoplay-lightbox').checked = State.settings.AUTOPLAY_LIGHTBOX_VIDEOS; document.getElementById('setting-hide-scrollbars').checked = State.settings.HIDE_PAGE_SCROLLBARS; document.getElementById('setting-preview-muted').checked = State.settings.PREVIEW_VIDEOS_MUTED; document.getElementById('setting-preview-loop').checked = State.settings.PREVIEW_VIDEOS_LOOP; document.getElementById('setting-zoom').value = State.settings.ZOOM_SCALE_FACTOR; document.getElementById('setting-lightbox-bg').value = State.settings.LIGHTBOX_BG_COLOR; document.getElementById('setting-deck-preload-ahead').value = State.settings.DECK_PRELOAD_AHEAD; document.getElementById('setting-deck-preload-behind').value = State.settings.DECK_PRELOAD_BEHIND; const zoomSensInput = document.getElementById('setting-zoom-sens'); zoomSensInput.value = State.settings.ZOOM_SENSITIVITY; document.getElementById('zoom-sens-value').textContent = parseFloat(zoomSensInput.value).toFixed(2); document.getElementById('setting-key-gallery-next').value = Utils.formatHotkeyForDisplay(State.settings.KEY_GALLERY_NEXT_PAGE); document.getElementById('setting-key-gallery-prev').value = Utils.formatHotkeyForDisplay(State.settings.KEY_GALLERY_PREV_PAGE); document.getElementById('setting-key-peek-vid-play').value = Utils.formatHotkeyForDisplay(State.settings.KEY_PEEK_VIDEO_PLAY_PAUSE); document.getElementById('setting-key-peek-vid-fwd').value = Utils.formatHotkeyForDisplay(State.settings.KEY_PEEK_VIDEO_SEEK_FORWARD); document.getElementById('setting-key-peek-vid-back').value = Utils.formatHotkeyForDisplay(State.settings.KEY_PEEK_VIDEO_SEEK_BACK); document.getElementById('setting-key-deck-next').value = Utils.formatHotkeyForDisplay(State.settings.KEY_DECK_NEXT_MEDIA); document.getElementById('setting-key-deck-prev').value = Utils.formatHotkeyForDisplay(State.settings.KEY_DECK_PREV_MEDIA); document.getElementById('setting-key-deck-jump').value = Utils.formatHotkeyForDisplay(State.settings.KEY_DECK_JUMP_BOX); document.getElementById('setting-key-deck-bookmark').value = Utils.formatHotkeyForDisplay(State.settings.KEY_DECK_TOGGLE_BOOKMARK); document.getElementById('setting-key-deck-close').value = Utils.formatHotkeyForDisplay(State.settings.KEY_DECK_CLOSE_PANELS); document.getElementById('setting-api-key').value = State.settings.API_KEY; document.getElementById('setting-user-id').value = State.settings.USER_ID; document.getElementById('setting-api-url').value = State.settings.API_BASE_URL; 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('enhancer-api-status').style.display = 'none'; document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`).style.display = 'flex'; }, closeSettingsModal: function() { const overlay = document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`); if (overlay) overlay.style.display = 'none'; }, 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: {} }; State.previewElements = elements; return elements; }, destroyPreviewElement: function() { if (!State.previewElements) return; Logger.log('Destroying Peek Preview element and its listeners.'); if (State.pendingPreviewRequest) { State.pendingPreviewRequest.abort(); State.pendingPreviewRequest = null; } const { previewContainer, handlers } = State.previewElements; previewContainer.removeEventListener('mouseenter', handlers.stopHideTimer); previewContainer.removeEventListener('mouseleave', handlers.startHideTimer); previewContainer.removeEventListener('click', handlers.handlePreviewClick); previewContainer.removeEventListener('keydown', handlers.handlePreviewKeyDown); State.previewElements.videoLayer.removeEventListener('timeupdate', handlers.handleVideoTimeUpdate); State.previewElements.seekBar.removeEventListener('input', handlers.handleSeekBarInput); State.previewElements.seekBarContainer.removeEventListener('click', handlers.handleSeekBarContainerClick); window.removeEventListener('scroll', handlers.handleInstantHideOnScroll); if (previewContainer) { previewContainer.remove(); } State.previewElements = null; }, hidePreview: function() { if (!State.previewElements) return; if (State.pendingPreviewRequest) { State.pendingPreviewRequest.abort(); State.pendingPreviewRequest = null; } const { previewContainer, videoLayer } = State.previewElements; videoLayer.pause(); videoLayer.removeAttribute('src'); videoLayer.load(); previewContainer.classList.remove('show', 'video-active', 'error', 'loading'); previewContainer.blur(); 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 * State.settings.ZOOM_SCALE_FACTOR; const finalHeight = initialHeight * State.settings.ZOOM_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 (!State.previewElements) return; if (State.pendingPreviewRequest) { State.pendingPreviewRequest.abort(); State.pendingPreviewRequest = null; } State.currentThumb = thumb; const { previewContainer, lowResImg, highResImg, videoLayer, errorMessage } = 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 (State.settings.PREVIEW_QUALITY === 'low') return; previewContainer.classList.add('loading'); try { const postId = Utils.getPostId(thumb.href); const media = await API.fetchMediaDetails(postId); if (State.currentThumb !== thumb) return; previewContainer.classList.remove('loading'); if (media.type === 'video') { videoLayer.src = media.url; videoLayer.loop = State.settings.PREVIEW_VIDEOS_LOOP; videoLayer.muted = State.settings.PREVIEW_VIDEOS_MUTED; videoLayer.play().catch(() => {}); videoLayer.onloadedmetadata = () => { if (State.currentThumb === thumb) { const seekStep = videoLayer.duration * 0.05; 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 (State.currentThumb === thumb) { highResImg.style.display = 'block'; highResImg.style.opacity = '1'; } }; } } catch (error) { if (error.message.includes('abort')) return; if (State.currentThumb === thumb) { previewContainer.classList.remove('loading'); previewContainer.classList.add('error'); errorMessage.textContent = error.message; } } }, setupThumbnailEventListeners: function(thumbnailGrid) { if (!State.previewElements) return () => {}; const { handlers, previewContainer, videoLayer, seekBarContainer, seekBar } = State.previewElements; handlers.handleInstantHideOnScroll = () => { if (!State.previewElements?.previewContainer || !State.currentThumb) { return; } const { previewContainer } = State.previewElements; previewContainer.style.transition = 'none'; UI.hidePreview(); setTimeout(() => { if (previewContainer) { previewContainer.style.transition = ''; } }, 50); }; handlers.startHideTimer = () => { clearTimeout(State.showTimeout); clearTimeout(State.hideTimeout); State.hideTimeout = setTimeout(() => UI.hidePreview(), State.settings.HIDE_DELAY); }; handlers.stopHideTimer = () => { clearTimeout(State.hideTimeout); }; handlers.handleGridMouseOver = (e) => { const thumb = e.target.closest(Config.SELECTORS.THUMBNAIL_ANCHOR_SELECTOR); if (thumb) { handlers.stopHideTimer(); if (State.lastHoveredThumb && State.lastHoveredThumb !== thumb) { const oldTitle = State.lastHoveredThumb.dataset.originalTitle; if (oldTitle) State.lastHoveredThumb.setAttribute('title', oldTitle); } if (thumb.hasAttribute('title')) { thumb.dataset.originalTitle = thumb.getAttribute('title'); thumb.removeAttribute('title'); } State.lastHoveredThumb = thumb; if (State.currentThumb !== thumb) { if (State.currentThumb) UI.hidePreview(); State.showTimeout = setTimeout(() => UI.showPreview(thumb), State.settings.SHOW_DELAY); } } else if (!previewContainer.matches(':hover')) { handlers.startHideTimer(); } }; handlers.handleGridMouseLeave = () => { if (State.lastHoveredThumb) { const oldTitle = State.lastHoveredThumb.dataset.originalTitle; if (oldTitle) State.lastHoveredThumb.setAttribute('title', oldTitle); State.lastHoveredThumb = null; } handlers.startHideTimer(); }; handlers.handlePreviewClick = () => { if (State.currentThumb?.href) GM_openInTab(State.currentThumb.href, { active: false, setParent: true }); }; handlers.handlePreviewKeyDown = e => { if (State.currentThumb && videoLayer.style.opacity === '1') { const hotkeys = [State.settings.KEY_PEEK_VIDEO_SEEK_BACK, State.settings.KEY_PEEK_VIDEO_SEEK_FORWARD, State.settings.KEY_PEEK_VIDEO_PLAY_PAUSE]; if (hotkeys.includes(e.key)) e.preventDefault(); if (e.key === State.settings.KEY_PEEK_VIDEO_SEEK_BACK) videoLayer.currentTime -= State.dynamicSeekTime; else if (e.key === State.settings.KEY_PEEK_VIDEO_SEEK_FORWARD) videoLayer.currentTime += State.dynamicSeekTime; else if (e.key === State.settings.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); }; }, createLightboxForContent: function() { let zoomDestroyer = null; let contentElement = document.querySelector(Config.SELECTORS.IMAGE_SELECTOR) || document.querySelector(Config.SELECTORS.VIDEO_PLAYER_SELECTOR); let wrapperToRemove = null; if (contentElement && contentElement.parentElement.classList.contains('enhancer-reopen-wrapper')) { wrapperToRemove = contentElement.parentElement; } if (!contentElement || document.getElementById('enhancer-lightbox-overlay')) return; const originalParent = wrapperToRemove ? wrapperToRemove.parentElement : contentElement.parentElement; const originalNextSibling = wrapperToRemove ? wrapperToRemove.nextElementSibling : contentElement.nextElementSibling; const originalStyles = { cursor: contentElement.style.cursor, maxWidth: contentElement.style.maxWidth, maxHeight: contentElement.style.maxHeight }; const overlay = document.createElement('div'); overlay.id = 'enhancer-lightbox-overlay'; overlay.style.backgroundColor = State.settings.LIGHTBOX_BG_COLOR; document.body.appendChild(overlay); contentElement.classList.add('enhancer-lightbox-content'); if (wrapperToRemove) { overlay.appendChild(contentElement); wrapperToRemove.remove(); } else { overlay.appendChild(contentElement); } if (contentElement.tagName === 'VIDEO' && State.settings.AUTOPLAY_LIGHTBOX_VIDEOS) { contentElement.muted = false; contentElement.play().catch(() => {}); } if (contentElement.tagName === 'IMG') { zoomDestroyer = Zoom.enable(contentElement, { clickAction: 'toggle_zoom' }); } const closeLightbox = () => { if (zoomDestroyer) zoomDestroyer.destroy(); contentElement.style.transform = ''; contentElement.style.cursor = ''; if (originalParent) { if (contentElement.tagName === 'VIDEO') { const videoWrapper = document.createElement('div'); videoWrapper.className = 'enhancer-reopen-wrapper'; videoWrapper.style.position = 'relative'; videoWrapper.style.display = 'inline-block'; videoWrapper.style.lineHeight = '0'; const clickOverlay = document.createElement('div'); clickOverlay.style.position = 'absolute'; clickOverlay.style.top = '0'; clickOverlay.style.left = '0'; clickOverlay.style.width = '100%'; clickOverlay.style.height = '100%'; clickOverlay.style.cursor = 'zoom-in'; clickOverlay.style.zIndex = '1'; videoWrapper.appendChild(contentElement); videoWrapper.appendChild(clickOverlay); if (originalNextSibling) { originalParent.insertBefore(videoWrapper, originalNextSibling); } else { originalParent.appendChild(videoWrapper); } clickOverlay.addEventListener('click', () => { UI.createLightboxForContent(); }, { once: true }); } else { if (originalNextSibling) { originalParent.insertBefore(contentElement, originalNextSibling); } else { originalParent.appendChild(contentElement); } contentElement.addEventListener('click', UI.createLightboxForContent, { once: true }); } Object.assign(contentElement.style, originalStyles); contentElement.classList.remove('enhancer-lightbox-content'); } overlay.remove(); document.removeEventListener('keydown', escapeHandler); }; const escapeHandler = e => { if (e.key === "Escape") closeLightbox(); }; overlay.addEventListener('click', e => { if (e.target === overlay) closeLightbox(); }); document.addEventListener('keydown', escapeHandler, { once: true }); }, _getDeckBaseStyles: function() { return ` #${Config.SELECTORS.DECK_VIEWER_ID} { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: #111 !important; z-index: 99998; display: flex; flex-direction: column; font-family: sans-serif; } .hide-cursor, .hide-cursor * { cursor: none !important; } #viewer-main { position: relative; flex-grow: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; } #viewer-main:focus { outline: none; } .media-slot { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.1s ease-in-out; z-index: 5; pointer-events: none; } .media-slot.active { opacity: 1; z-index: 10; pointer-events: auto; } .media-wrapper { position: relative; display: inline-block; vertical-align: middle; line-height: 0; } .media-wrapper > img, .media-wrapper > video { max-width: 98vw; max-height: 88vh; object-fit: contain; } #viewer-main img { cursor: pointer; transform-origin: 0 0; transition: transform .1s linear; } .loading-text { color: #fff; text-align: center; padding: 50px; font-size: 1.5em; z-index: 100; position: absolute; } `; }, _getDeckUIComponentsStyles: function() { return ` .nav-btn { position: absolute; top: 50%; transform: translateY(-50%); background: 0; color: #fff; border: none; font-size: 120px; cursor: pointer; opacity: 0; transition: opacity .2s; user-select: none; z-index: 20; text-shadow: 0 0 10px #000; } #prev-btn { left: 100px; } #next-btn { right: 100px; } .nav-hotzone { position: absolute; top: 50%; transform: translateY(-50%); height: 300px; width: 15%; z-index: 15; } #left-hotzone { left: 0; } #right-hotzone { right: 0; } #left-hotzone:hover ~ #prev-btn, #prev-btn:hover, #right-hotzone:hover ~ #next-btn, #next-btn:hover { opacity: .6; } #bottom-bar { display: flex; align-items: center; background-color: #222; padding: 5px; flex-shrink: 0; height: 12vh; min-height: 80px; box-sizing: border-box; opacity: .2; transition: opacity .3s ease-in-out; z-index: 25; } #bottom-bar.visible { opacity: 1; } #jump-to-page-btn, #bookmarks-panel-btn { position: relative; width: 50px; height: 50px; margin: 0 10px; background-color: #444; color: #fff; border: 1px solid #666; border-radius: 5px; font-size: 24px; cursor: pointer; flex-shrink: 0; } #jump-to-page-btn:hover, #bookmarks-panel-btn:hover { background-color: #555; } #bookmark-count { position: absolute; bottom: 2px; right: 4px; font-size: 11px; font-weight: bold; background-color: #d00; color: #fff; border-radius: 50%; width: 16px; height: 16px; line-height: 16px; text-align: center; } #viewer-thumbnails { flex-grow: 1; text-align: center; height: 100%; overflow-x: auto; white-space: nowrap; box-sizing: border-box; } .thumb-container { position: relative; display: inline-block; height: 90%; width: 75px; margin: 0 4px; border: 2px solid transparent; cursor: pointer; vertical-align: middle; overflow: hidden; border-radius: 4px; } .thumb-img { height: 100%; width: 100%; display: block; object-fit: cover; } .thumb-container:hover { border-color: #fff; } .thumb-container.active { border-color: #006FFA; } .thumb-container.broken-thumb { border-color: #f55; opacity: 0.6; } .thumb-container.broken-thumb .thumb-img { filter: grayscale(1); } .video-icon { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: #fffa; font-size: 30px; text-shadow: 0 0 8px #0008; pointer-events: none; } .retry-icon { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 30px; text-shadow: 0 0 8px #000; z-index: 2; } .thumb-container.broken-thumb:hover .retry-icon { display: block; } #tags-menu { position: fixed; left: -300px; top: 0; width: 300px; height: 88vh; background-color: #000d; color: #fff; transition: left .3s; z-index: 30; padding: 10px; box-sizing: border-box; overflow-y: auto; } body:not(.tag-menu-disabled) #tags-trigger:hover + #tags-menu, #tags-menu:hover { left: 0; } #tags-trigger { position: fixed; left: 0; top: 0; width: 2px; height: 88vh; z-index: 29; } #tags-list { list-style: none; padding: 0; margin: 0; } .media-action-btn { position: absolute; z-index: 25; color: #fff !important; text-decoration: none; user-select: none; opacity: 0; transition: opacity .2s; background: rgba(0,0,0,0.4); border-radius: 5px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; text-shadow: 0 0 10px #000; cursor: pointer; border: none; } .open-post-btn { top: 10px; right: 10px; } #bookmark-btn { top: 10px; left: 10px; } .media-wrapper:hover .media-action-btn { opacity: 0.7; } .media-action-btn:hover { opacity: 1; } `; }, _getDeckOverlaysStyles: function() { return ` #bookmarks-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 99999; display: flex; justify-content: center; align-items: center; } #bookmarks-overlay.hidden { display: none; } #bookmarks-panel { width: 80vw; max-width: 1200px; height: 80vh; display: flex; flex-direction: column; background-color: #252525; color: #eee; border: 1px solid #666; border-radius: 8px; z-index: 100000; padding: 20px; box-sizing: border-box;} #bookmarks-panel h3 { margin: 0 0 15px 0; padding-bottom: 10px; border-bottom: 1px solid #666; text-align: center; } #bookmarked-thumbs-list { flex-grow: 1; overflow-y: auto; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; align-content: flex-start; } #bookmarked-thumbs-list .thumb-container { height: 150px; width: 150px; } .empty-list-msg { color: #888; text-align: center; width: 100%; align-self: center; } .bookmark-delete-btn { position: absolute; top: 0; right: 0; width: 24px; height: 24px; background: rgba(0,0,0,0.6); color: #fff; border: none; cursor: pointer; font-size: 14px; opacity: 0; transition: opacity .2s; border-radius: 0 4px 0 4px; } #bookmarked-thumbs-list .thumb-container:hover .bookmark-delete-btn { opacity: 1; } .bookmark-delete-btn:hover { background: #f44336; } #bookmark-actions { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 15px; border-top: 1px solid #555; } #bookmark-import-area { flex-grow: 1; height: 40px; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; padding: 5px; box-sizing: border-box; resize: none; min-width: 0; } .bookmark-buttons { display: flex; gap: 10px; } .bookmark-buttons button { background-color: #444; color: #fff; border: 1px solid #666; padding: 8px 12px; border-radius: 4px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; } .bookmark-buttons button:hover { background-color: #555; } #bookmark-info-msg { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); font-size: 12px; color: #999; height: 14px; transition: color .3s; } @keyframes shake { 10%, 90% { transform: translate(-1px, 0); } 20%, 80% { transform: translate(2px, 0); } 30%, 50%, 70% { transform: translate(-4px, 0); } 40%, 60% { transform: translate(4px, 0); } } #jump-box-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 99999; display: flex; justify-content: center; align-items: center; opacity: 1; transition: opacity 0.2s; } #jump-box-overlay.hidden { opacity: 0; pointer-events: none; } #jump-box { background-color: #252525; padding: 20px; border-radius: 8px; border: 1px solid #666; box-shadow: 0 5px 25px #000a; width: auto; min-width: 480px; text-align: center; position: relative; padding-bottom: 70px; } #jump-box.invalid .jump-controls { animation: shake 0.5s; } #jump-box.invalid #jump-input { border-color: #f55; } .jump-controls { display: flex; align-items: center; justify-content: center; gap: 15px; padding: 5px; margin-top: 10px; transition: border-color .2s; } #jump-box.loading .jump-controls, #jump-box.loading #pagination-bar, #jump-box.loading #jump-history { display: none; } #jump-box.loading #jump-spinner { display: flex; } #jump-input { width: 180px; font-size: 1.4em; padding: 8px; background: #333; border: 1px solid #555; color: #fff; border-radius: 4px; text-align: center; } #jump-spinner { display: none; font-size: 2em; padding: 20px; justify-content: center; align-items: center; } #jump-history { margin-top: 20px; border-top: 1px solid #444; padding-top: 15px; } #jump-history h4 { margin: 0 0 10px 0; font-size: 0.9em; color: #aaa; text-align: left; } .history-list { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; } .history-item { background-color: #383838; color: #ddd; border: 1px solid #555; padding: 5px 10px; border-radius: 4px; cursor: pointer; transition: background-color .2s; } .history-item:hover { background-color: #4a4a4a; } #exit-deck-btn { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 8px 40px; background-color: #c0392b; color: #fff; border: 1px solid #a03024; border-radius: 4px; cursor: pointer; font-weight: bold; white-space: nowrap; } #exit-deck-btn:hover { background-color: #e74c3c; } #pagination-bar { display: flex; justify-content: center; align-items: center; gap: 5px; margin-bottom: 15px; font-size: 1.1em; font-weight: bold; } .pagination-btn { background: #383838; color: #ddd; border: 1px solid #555; padding: 6px 12px; border-radius: 4px; cursor: pointer; transition: background-color .2s; font-weight: normal; } .pagination-btn:hover { background-color: #4a4a4a; } .pagination-btn.active { background-color: #006FFA; color: #111; border-color: #006FFA; font-weight: bold; } .pagination-ellipsis { color: #777; padding: 0 5px; font-weight: bold; } `; }, setupViewer: function() { if (document.getElementById(Config.SELECTORS.DECK_VIEWER_ID)) return; const deckStyles = `${this._getDeckBaseStyles()} ${this._getDeckUIComponentsStyles()} ${this._getDeckOverlaysStyles()}`; GM_addStyle(deckStyles); const viewerHTML = `<div id="viewer-main" tabindex="-1"><div class="media-slot"></div><div class="media-slot"></div><div class="loading-text">Pre-loading gallery...</div><div id="left-hotzone" class="nav-hotzone"></div><div id="right-hotzone" class="nav-hotzone"></div><button id="prev-btn" class="nav-btn"><i class="fas fa-chevron-left"></i></button><button id="next-btn" class="nav-btn"><i class="fas fa-chevron-right"></i></button></div><div id="bottom-bar"><button id="bookmarks-panel-btn" title="Session Bookmarks"><i class="far fa-bookmark"></i><span id="bookmark-count">0</span></button><div id="viewer-thumbnails"></div><button id="jump-to-page-btn" title="Go to page (Enter)"><i class="fas fa-map-marker-alt"></i></button></div><div id="tags-trigger"></div><div id="tags-menu"><h3>Tags</h3><ul id="tags-list"></ul></div><div id="jump-box-overlay" class="hidden"><div id="jump-box"><div id="pagination-bar"></div><div class="jump-controls"><input type="text" id="jump-input" placeholder="(+10, -5, 123...)"></div><div id="jump-history"></div><div id="jump-spinner" class="hidden"><i class="fas fa-spinner fa-spin"></i></div><button id="exit-deck-btn" title="Close the Deck Viewer">Exit Deck</button></div></div><div id="bookmarks-overlay" class="hidden"><div id="bookmarks-panel"><h3>Bookmarks</h3><div id="bookmarked-thumbs-list"></div><div id="bookmark-actions"><textarea id="bookmark-import-area" placeholder="Paste exported bookmark data here..."></textarea><div class="bookmark-buttons"><button id="bookmark-export-btn" title="Copy current bookmarks to clipboard"><i class="fas fa-clipboard"></i> Export</button><button id="bookmark-import-btn" title="Merge bookmarks from text area into current session"><i class="fas fa-paste"></i> Import</button></div></div><p id="bookmark-info-msg"></p></div></div>`; const viewerOverlay = document.createElement('div'); viewerOverlay.id = Config.SELECTORS.DECK_VIEWER_ID; viewerOverlay.innerHTML = viewerHTML; document.body.appendChild(viewerOverlay); document.body.style.overflow = 'hidden'; this.mainContainer = viewerOverlay.querySelector('#viewer-main'); this.thumbContainer = viewerOverlay.querySelector('#viewer-thumbnails'); this.tagsList = viewerOverlay.querySelector('#tags-list'); this.setupDeckEventListeners(); }, destroyViewer: function() { clearTimeout(this.inactivityTimer); const viewerOverlay = document.getElementById(Config.SELECTORS.DECK_VIEWER_ID); if (viewerOverlay) { viewerOverlay.remove(); } document.body.style.overflow = ''; this.mainContainer = null; this.thumbContainer = null; this.tagsList = null; }, setupDeckEventListeners: function() { const jumpBtn = document.getElementById('jump-to-page-btn'); if (jumpBtn) jumpBtn.addEventListener('click', () => this.toggleJumpBox(true)); const jumpOverlay = document.getElementById('jump-box-overlay'); if (jumpOverlay) { const jumpInput = document.getElementById('jump-input'); jumpOverlay.addEventListener('click', (e) => { if (e.target === jumpOverlay) this.toggleJumpBox(false); }); if (jumpInput) { jumpInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); if (jumpInput.value.trim() === '') { this.toggleJumpBox(false); } else { this.processJumpRequest(jumpInput.value); } } else if (e.key === 'Escape') { e.stopPropagation(); this.toggleJumpBox(false); } }); } } const exitDeckBtn = document.getElementById('exit-deck-btn'); if (exitDeckBtn) { exitDeckBtn.addEventListener('click', () => { App.closeViewer(); }); } const bookmarksBtn = document.getElementById('bookmarks-panel-btn'); if (bookmarksBtn) bookmarksBtn.addEventListener('click', () => this.toggleBookmarksPanel(true)); const bookmarksOverlay = document.getElementById('bookmarks-overlay'); if (bookmarksOverlay) { bookmarksOverlay.addEventListener('click', (e) => { if (e.target === bookmarksOverlay) this.toggleBookmarksPanel(false); }); document.getElementById('bookmark-export-btn').addEventListener('click', this.handleBookmarkExport); document.getElementById('bookmark-import-btn').addEventListener('click', this.handleBookmarkImport); } if (this.thumbContainer) { this.thumbContainer.addEventListener('wheel', (event) => { if (event.currentTarget.scrollWidth > event.currentTarget.clientWidth) { event.preventDefault(); event.currentTarget.scrollLeft += event.deltaY; } }, { passive: false }); } const bottomBarInteractables = [document.getElementById('jump-to-page-btn'), document.getElementById('bookmarks-panel-btn'), document.getElementById('viewer-thumbnails')]; bottomBarInteractables.forEach(elem => { if (elem) { elem.addEventListener('mouseenter', () => document.getElementById(Config.SELECTORS.DECK_VIEWER_ID).classList.add('tag-menu-disabled')); elem.addEventListener('mouseleave', () => document.getElementById(Config.SELECTORS.DECK_VIEWER_ID).classList.remove('tag-menu-disabled')); } }); const prevBtn = document.getElementById('prev-btn'); if (prevBtn) prevBtn.onclick = () => App.loadMedia(State.currentIndex - 1); const nextBtn = document.getElementById('next-btn'); if (nextBtn) nextBtn.onclick = () => App.loadMedia(State.currentIndex + 1); this.setupCursorEvents(); }, displayMedia: function(mediaUrl, type, tags) { const loadingEl = this.mainContainer.querySelector('.loading-text'); if (loadingEl) loadingEl.style.display = 'none'; this.updateTagsList(tags); if (type === 'image') { const slots = this.mainContainer.querySelectorAll('.media-slot'); const activeSlot = this.mainContainer.querySelector('.media-slot.active'); const inactiveSlot = Array.from(slots).find(s => !s.classList.contains('active')) || slots[0]; if (!inactiveSlot) { return; } inactiveSlot.innerHTML = ''; const mediaWrapper = document.createElement('div'); mediaWrapper.className = 'media-wrapper'; const mediaElement = document.createElement('img'); Zoom.enable(mediaElement); const bookmarkBtn = document.createElement('button'); bookmarkBtn.id = 'bookmark-btn'; bookmarkBtn.className = 'media-action-btn'; bookmarkBtn.title = `Bookmark (${State.settings.KEY_DECK_TOGGLE_BOOKMARK.toUpperCase()})`; bookmarkBtn.innerHTML = '<i class="far fa-star"></i>'; bookmarkBtn.onclick = () => App.toggleBookmark(); mediaWrapper.appendChild(mediaElement); mediaWrapper.appendChild(bookmarkBtn); inactiveSlot.appendChild(mediaWrapper); const swapBuffers = () => { inactiveSlot.classList.add('active'); if (activeSlot) { activeSlot.classList.remove('active'); setTimeout(() => { if (!activeSlot.classList.contains('active')) { activeSlot.innerHTML = ''; } }, 200); } this.updateBookmarkButtonState(); }; mediaElement.onload = swapBuffers; mediaElement.onerror = () => Logger.error("Failed to load image:", mediaUrl); mediaElement.src = mediaUrl; if (mediaElement.complete) { swapBuffers(); } } else if (type === 'video') { const slots = this.mainContainer.querySelectorAll('.media-slot'); slots.forEach(slot => { slot.classList.remove('active'); slot.innerHTML = ''; }); const targetSlot = slots[0]; if (!targetSlot) { return; } const mediaWrapper = document.createElement('div'); mediaWrapper.className = 'media-wrapper'; const mediaElement = document.createElement('video'); mediaElement.autoplay = true; mediaElement.loop = true; mediaElement.controls = true; mediaElement.src = mediaUrl; const openPostBtn = document.createElement('button'); openPostBtn.className = 'open-post-btn media-action-btn'; openPostBtn.innerHTML = '<i class="fas fa-external-link-alt"></i>'; openPostBtn.title = 'Open post page in new tab'; openPostBtn.addEventListener('click', (e) => { e.preventDefault(); GM_openInTab(State.galleryData.posts[State.currentIndex].postUrl, { active: false, setParent: true }); }); mediaWrapper.appendChild(openPostBtn); const bookmarkBtn = document.createElement('button'); bookmarkBtn.id = 'bookmark-btn'; bookmarkBtn.className = 'media-action-btn'; bookmarkBtn.title = `Bookmark (${State.settings.KEY_DECK_TOGGLE_BOOKMARK.toUpperCase()})`; bookmarkBtn.innerHTML = '<i class="far fa-star"></i>'; bookmarkBtn.onclick = () => App.toggleBookmark(); mediaWrapper.appendChild(mediaElement); mediaWrapper.appendChild(bookmarkBtn); targetSlot.appendChild(mediaWrapper); targetSlot.classList.add('active'); this.updateBookmarkButtonState(); } }, handleBookmarkExport: function() { const infoMsg = document.getElementById('bookmark-info-msg'); infoMsg.textContent = ''; if (State.bookmarks.length === 0) { infoMsg.textContent = 'Nothing to export.'; setTimeout(() => { infoMsg.textContent = ''; }, 3000); return; } const jsonString = JSON.stringify(State.bookmarks, null, 2); navigator.clipboard.writeText(jsonString).then(() => { infoMsg.textContent = `Exported ${State.bookmarks.length} bookmarks to clipboard!`; setTimeout(() => { infoMsg.textContent = ''; }, 3000); }).catch(err => { Logger.error("Failed to export bookmarks:", err); infoMsg.textContent = 'Failed to copy to clipboard.'; setTimeout(() => { infoMsg.textContent = ''; }, 3000); }); }, handleBookmarkImport: function() { const infoMsg = document.getElementById('bookmark-info-msg'); infoMsg.textContent = ''; const textarea = document.getElementById('bookmark-import-area'); const jsonString = textarea.value; if (!jsonString.trim()) { infoMsg.textContent = 'Import field is empty.'; setTimeout(() => { infoMsg.textContent = ''; }, 3000); return; } try { const importedData = JSON.parse(jsonString); if (!Array.isArray(importedData) || (importedData.length > 0 && (!importedData[0].id || !importedData[0].postUrl || !importedData[0].thumbUrl))) { throw new Error("Invalid data format."); } const existingIds = new Set(State.bookmarks.map(b => b.id)); let newBookmarksAdded = 0; importedData.forEach(newBookmark => { if (!existingIds.has(newBookmark.id)) { State.bookmarks.push(newBookmark); newBookmarksAdded++; } }); App.updateUIAfterBookmarkChange(); textarea.value = ''; infoMsg.textContent = `Import complete. ${newBookmarksAdded} new bookmark(s) added.`; setTimeout(() => { infoMsg.textContent = ''; }, 3000); } catch (error) { Logger.error("Failed to import bookmarks:", error); infoMsg.textContent = `Import failed: ${error.message}`; setTimeout(() => { infoMsg.textContent = ''; }, 3000); } }, toggleBookmarksPanel: function(show) { const overlay = document.getElementById('bookmarks-overlay'); if (show === false || !overlay.classList.contains(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN)) { overlay.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN); } else { this.renderBookmarkedThumbnails(); overlay.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN); } }, renderBookmarkedThumbnails: function() { const list = document.getElementById('bookmarked-thumbs-list'); list.innerHTML = ''; if (State.bookmarks.length === 0) { list.innerHTML = `<p class="empty-list-msg">No posts bookmarked.</p>`; return; } const fragment = document.createDocumentFragment(); State.bookmarks.forEach(bookmark => { const container = document.createElement('div'); container.className = 'thumb-container'; container.title = `Post ID: ${bookmark.id}\nClick to open in new tab.`; container.onclick = () => GM_openInTab(bookmark.postUrl, { active: true, setParent: true }); const thumbImg = document.createElement('img'); thumbImg.className = 'thumb-img'; thumbImg.src = bookmark.thumbUrl; const deleteBtn = document.createElement('button'); deleteBtn.className = 'bookmark-delete-btn'; deleteBtn.innerHTML = '<i class="fas fa-times"></i>'; deleteBtn.title = 'Remove bookmark'; deleteBtn.onclick = (e) => { e.stopPropagation(); App.removeBookmark(bookmark.id); }; container.append(thumbImg, deleteBtn); fragment.appendChild(container); }); list.appendChild(fragment); }, updateBookmarkCounter: function() { const countEl = document.getElementById('bookmark-count'); if (!countEl) return; const btnEl = document.getElementById('bookmarks-panel-btn'); if (!btnEl) return; const iconEl = btnEl.querySelector('i'); const count = State.bookmarks.length; countEl.textContent = count; if (count > 0) { btnEl.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); if (iconEl) iconEl.style.color = '#daa520'; } else { btnEl.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); if (iconEl) iconEl.style.color = ''; } }, updateBookmarkButtonState: function() { const btn = document.querySelector('.media-slot.active #bookmark-btn'); if (!btn) return; const icon = btn.querySelector('i'); const currentPost = State.galleryData.posts[State.currentIndex]; if (!currentPost) return; const currentId = Utils.getPostId(currentPost.postUrl); const isBookmarked = State.bookmarks.some(b => b.id === currentId); if (isBookmarked) { btn.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); icon.className = 'fas fa-star'; icon.style.color = '#daa520'; } else { btn.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); icon.className = 'far fa-star'; icon.style.color = ''; } }, toggleJumpBox: function(show) { const jumpOverlay = document.getElementById('jump-box-overlay'); const jumpInput = document.getElementById('jump-input'); const jumpBox = document.getElementById('jump-box'); jumpBox.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.LOADING, Config.DECK_CONSTANTS.CSS_CLASSES.INVALID); if (show === false || !jumpOverlay.classList.contains(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN)) { jumpOverlay.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN); jumpInput.value = ''; if (UI.mainContainer) UI.mainContainer.focus(); } else { jumpOverlay.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.HIDDEN); const currentPage = App.getCurrentPageNum(); const lastPage = State.galleryData.lastPageNum; this.renderPagination(currentPage, lastPage); this.updateHistoryDisplay(); jumpInput.focus(); } }, processJumpRequest: function(value) { const jumpBox = document.getElementById('jump-box'); jumpBox.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.INVALID); let targetPage = 0; const inputStr = String(value).trim(); if (inputStr.startsWith('+') || inputStr.startsWith('-')) { const relative = parseInt(inputStr, 10); if (!isNaN(relative)) { targetPage = App.getCurrentPageNum() + relative; } } else { const absolute = parseInt(inputStr, 10); if (!isNaN(absolute)) { targetPage = absolute; } } if (targetPage > 0) { jumpBox.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.LOADING); setTimeout(() => { this.toggleJumpBox(false); App.performJump(targetPage); }, 200); } else { jumpBox.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.INVALID); setTimeout(() => jumpBox.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.INVALID), 500); } }, updateHistoryDisplay: function() { const historyContainer = document.getElementById('jump-history'); historyContainer.innerHTML = ''; if (State.navigationHistory.length === 0) return; const title = document.createElement('h4'); title.textContent = 'History:'; historyContainer.appendChild(title); const list = document.createElement('div'); list.className = 'history-list'; const fragment = document.createDocumentFragment(); State.navigationHistory.forEach(pageNum => { const item = document.createElement('button'); item.className = 'history-item'; item.textContent = `${pageNum}`; item.onclick = () => this.processJumpRequest(pageNum); fragment.appendChild(item); }); list.appendChild(fragment); historyContainer.appendChild(list); }, generatePagination: function(current, total) { if (total <= 1) return []; if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); const pages = new Set([1, total, current]); for (let i = -2; i <= 2; i++) { const page = current + i; if (page > 1 && page < total) pages.add(page); } const sortedPages = Array.from(pages).sort((a, b) => a - b); const result = []; let last = 0; for (const page of sortedPages) { if (page > last + 1) result.push('...'); result.push(page); last = page; } return result; }, renderPagination: function(current, total) { const container = document.getElementById('pagination-bar'); container.innerHTML = ''; const pages = this.generatePagination(current, total); if (pages.length === 0) { const fallbackText = document.createElement('span'); fallbackText.className = 'pagination-ellipsis'; fallbackText.textContent = `Current Page: ${current}`; container.appendChild(fallbackText); return; } const fragment = document.createDocumentFragment(); pages.forEach(page => { if (page === '...') { const ellipsis = document.createElement('span'); ellipsis.className = 'pagination-ellipsis'; ellipsis.textContent = '...'; fragment.appendChild(ellipsis); } else { const pageBtn = document.createElement('button'); pageBtn.className = 'pagination-btn'; if (page === current) pageBtn.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); pageBtn.textContent = page; pageBtn.onclick = () => this.processJumpRequest(page); fragment.appendChild(pageBtn); } }); container.appendChild(fragment); }, updateThumbnails: function() { this.thumbContainer.innerHTML = ''; const fragment = document.createDocumentFragment(); State.galleryData.posts.forEach((post, index) => { const container = document.createElement('div'); container.className = 'thumb-container'; container.dataset.index = index; container.onclick = () => App.loadMedia(index); if (index === State.currentIndex) { container.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE); } const thumbImg = document.createElement('img'); thumbImg.className = 'thumb-img'; thumbImg.src = post.thumbUrl; container.appendChild(thumbImg); const icon = document.createElement('i'); icon.className = 'fas fa-play-circle video-icon'; if (post.type === 'video') { icon.style.display = 'block'; } container.appendChild(icon); if (post.isBroken) { container.classList.add('broken-thumb'); const retryIcon = document.createElement('i'); retryIcon.className = 'fas fa-sync-alt retry-icon'; retryIcon.title = 'Retry loading this post'; retryIcon.onclick = (e) => { e.stopPropagation(); App.retryFetch(index); }; container.appendChild(retryIcon); } fragment.appendChild(container); }); this.thumbContainer.appendChild(fragment); const activeThumb = this.thumbContainer.querySelector(`.${Config.DECK_CONSTANTS.CSS_CLASSES.ACTIVE}`); if (activeThumb) { setTimeout(() => activeThumb.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }), 0); } }, updateTagsList: function(tags) { this.tagsList.innerHTML = ''; if (!tags) return; const fragment = document.createDocumentFragment(); const categoryOrder = ['artist', 'character', 'copyright', 'metadata', 'general']; categoryOrder.forEach(category => { if (tags[category]) { const h4 = document.createElement('h4'); h4.textContent = category.replace(/_/g, ' '); fragment.appendChild(h4); tags[category].forEach(tag => { const li = document.createElement('li'); const a = document.createElement('a'); a.href = tag.url; a.textContent = tag.name; a.target = '_blank'; const color = Config.DECK_CONSTANTS.TAG_COLORS[category.replace(/ /g, '_')] || Config.DECK_CONSTANTS.TAG_COLORS.general; a.style.setProperty('color', color, 'important'); li.appendChild(a); fragment.appendChild(li); }); } }); this.tagsList.appendChild(fragment); }, showLoadingMessage: function(message) { if (!this.mainContainer) return; const loadingEl = this.mainContainer.querySelector('.loading-text'); if (loadingEl) { loadingEl.textContent = message; loadingEl.style.display = 'block'; } this.mainContainer.querySelectorAll('.media-slot').forEach(slot => slot.classList.remove('active')); if (this.thumbContainer) { this.thumbContainer.innerHTML = ''; } }, setupCursorEvents: function() { const viewerOverlay = document.getElementById(Config.SELECTORS.DECK_VIEWER_ID); if (!viewerOverlay) return; viewerOverlay.addEventListener('mousemove', this.handleMouseMove.bind(this)); viewerOverlay.addEventListener('mousedown', this.resetCursorTimer.bind(this)); this.resetCursorTimer(); }, resetCursorTimer: function() { const viewerOverlay = document.getElementById(Config.SELECTORS.DECK_VIEWER_ID); if (!viewerOverlay) return; viewerOverlay.classList.remove('hide-cursor'); clearTimeout(this.inactivityTimer); this.inactivityTimer = setTimeout(() => viewerOverlay.classList.add('hide-cursor'), Config.DECK_CONSTANTS.CURSOR_INACTIVITY_TIME); }, handleMouseMove: function(e) { const bottomBar = document.getElementById('bottom-bar'); if (bottomBar && e.clientY > window.innerHeight * 0.88) { bottomBar.classList.add(Config.DECK_CONSTANTS.CSS_CLASSES.VISIBLE); } else if (bottomBar) { bottomBar.classList.remove(Config.DECK_CONSTANTS.CSS_CLASSES.VISIBLE); } this.resetCursorTimer(); }, addGlobalStyle: function() { let scrollbarCss = ''; if (State.settings.HIDE_PAGE_SCROLLBARS) { scrollbarCss = `html, body { scrollbar-width: none !important; -ms-overflow-style: none !important; } html::-webkit-scrollbar, body::-webkit-scrollbar { display: none !important; }`; } 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); } GM_addStyle(` ${scrollbarCss} .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: 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: 10vh; font-family: sans-serif; } #${Config.SELECTORS.ADVANCED_SEARCH_MODAL_ID} { background-color: #252525; border-radius: 8px; box-shadow: 0 5px 25px rgba(0,0,0,0.5); width: 90%; max-width: 800px; padding: 20px; border: 1px solid #666; max-height: 85vh; display: flex; flex-direction: column; } .gbs-search-title { margin: 0 0 15px 0; font-size: 1.2em; color: #eee; text-align: center; flex-shrink: 0; } .gbs-input-wrapper { position: relative; flex-shrink: 0; } #gbs-tag-input { width: 100%; box-sizing: border-box; background: #333; border: 1px solid #555; padding: 10px; border-radius: 4px; font-size: 1em; color: #eee; } #gbs-tag-input:focus { border-color: #006FFA; outline: none; } .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: 10px; } .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: 15px; font-size: 0.9em; font-weight: bold; } .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: 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 16px; border: 1px solid #666; border-radius: 4px; cursor: pointer; font-weight: bold; } .gbs-modal-button { background-color: #444; color: #fff; } .gbs-modal-button-primary { background-color: #006FFA; color: white; border-color: #006FFA; } #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-quick-tags-toggle-btn { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #aaa; cursor: pointer; transition: color 0.2s; font-size: 1.8em } #gbs-quick-tags-toggle-btn:hover, #gbs-quick-tags-toggle-btn:active, #gbs-quick-tags-toggle-btn.active { color: #daa520 !important; } #gbs-quick-tags-panel { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } #gbs-quick-tags-panel.hidden { display: none; } .gbs-modal-quick-tag { background-color: #4a4a4a; color: #ddd; border: 1px solid #555; padding: 4px 8px; border-radius: 50px; font-size: 1em; cursor: pointer; transition: background-color .2s, border-color .2s; user-select: none; } .gbs-modal-quick-tag:hover { background-color: #daa520; border-color: #daa520; color: white; } .thumbnail-preview img { border-radius: 8px !important; } .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: 8px; 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(${State.settings.ZOOM_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: 4px; 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; } #enhancer-lightbox-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; z-index: 20000; cursor: pointer; } .enhancer-lightbox-content { max-width: 95vw !important; max-height: 95vh !important; width: auto !important; height: auto !important; object-fit: contain !important; cursor: default !important; box-shadow: 0 0 30px rgba(0,0,0,0.5); } main #image.fit-width, main video#gelcomVideoPlayer { width: auto !important; margin: 0 !important; max-width: 70vw !important; max-height: 78vh !important; object-fit: contain !important; } `); } }; // ================================================================================= // MAIN APPLICATION MODULE // ================================================================================= const App = { thumbnailListenerCleanups: new Map(), deckKeyDownHandler: null, runAdvancedSearch: function() { if (!State.settings.ENABLE_ADVANCED_SEARCH) return; const originalInput = document.querySelector(Config.SELECTORS.SEARCH_INPUT); if (!originalInput) return; if (originalInput.form) { originalInput.form.classList.add('gbs-search-form'); } const { openModal } = UI.createAdvancedSearchModal(originalInput); const advButton = document.createElement('button'); advButton.type = 'button'; advButton.textContent = 'Advanced Search'; advButton.id = 'gbs-advanced-search-btn'; advButton.title = 'Open Advanced Tag Editor'; originalInput.insertAdjacentElement('afterend', advButton); advButton.addEventListener('click', (e) => { e.preventDefault(); openModal(); }); }, initializeThumbnailFeatures: function(grid) { if (grid.dataset.enhancerInitialized) return; grid.dataset.enhancerInitialized = 'true'; Logger.log('Initializing thumbnail features for grid:', grid); 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'); } } }); if (!State.previewElements) { UI.createPreviewElement(); } grid.querySelectorAll(Config.SELECTORS.THUMBNAIL_ANCHOR_SELECTOR).forEach(thumb => { thumb.removeAttribute('title'); thumb.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'] }); const cleanup = UI.setupThumbnailEventListeners(grid); this.thumbnailListenerCleanups.set(grid, { cleanupFunc: () => cleanup(stripTitleHandler), observer }); }, cleanupThumbnailFeatures: function(grid) { if (!grid.dataset.enhancerInitialized) return; Logger.log('Cleaning up thumbnail features for grid:', grid); const cleanupData = this.thumbnailListenerCleanups.get(grid); if (cleanupData) { cleanupData.cleanupFunc(); cleanupData.observer.disconnect(); this.thumbnailListenerCleanups.delete(grid); } if (this.thumbnailListenerCleanups.size === 0) { UI.destroyPreviewElement(); } delete grid.dataset.enhancerInitialized; }, cleanupAllFeatures: function() { Logger.log('Page is hiding. Cleaning up all residual features.'); for (const grid of this.thumbnailListenerCleanups.keys()) { this.cleanupThumbnailFeatures(grid); } }, setupGalleryNavigation: function() { document.addEventListener('keydown', e => { if (document.getElementById(Config.SELECTORS.DECK_VIEWER_ID)) return; 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 === State.settings.KEY_GALLERY_NEXT_PAGE) { targetLink = currentPageElement.nextElementSibling; } else if (e.key === State.settings.KEY_GALLERY_PREV_PAGE) { targetLink = currentPageElement.previousElementSibling; } if (targetLink?.tagName === 'A') { window.location.href = targetLink.href; } }); }, runViewer: async function() { await this.loadBookmarks(); UI.setupViewer(); UI.updateBookmarkCounter(); if (State.galleryData.posts && State.galleryData.posts.length > 0) { App.updateHistory(App.getCurrentPageNum()); this.loadMedia(State.currentIndex); this.deckKeyDownHandler = this.handleDeckKeyDown.bind(this); document.addEventListener('keydown', this.deckKeyDownHandler); } else { UI.showLoadingMessage("Error: Gallery data is empty or corrupt."); } }, closeViewer: function() { if (this.deckKeyDownHandler) { document.removeEventListener('keydown', this.deckKeyDownHandler); this.deckKeyDownHandler = null; } UI.destroyViewer(); }, runGalleryPageEnhancements: function() { if (State.settings.ENABLE_DECK_VIEWER) { const submenu = document.querySelector(Config.SELECTORS.galleryNavSubmenu); if (!submenu) { Logger.error("Gelbooru Suite Error: Could not find the navigation submenu to attach the viewer button."); return; } const viewerButton = document.createElement('a'); viewerButton.href = '#'; viewerButton.textContent = 'Open Deck'; viewerButton.style.cssText = 'color:#006FFA;font-weight:700'; submenu.appendChild(viewerButton); viewerButton.addEventListener('click', async (e) => { e.preventDefault(); if (viewerButton.textContent.includes('Loading')) return; const current = document.querySelector(Config.SELECTORS.PAGINATION_CURRENT_SELECTOR); State.galleryData.nextPageUrl = window.location.href; State.galleryData.prevPageUrl = current?.previousElementSibling?.href || null; State.galleryData.baseUrl = window.location.href; viewerButton.textContent = 'Loading API...'; try { const newIndex = await API.fetchPage(false); if (newIndex === null) { alert("Gelbooru Suite Error: API returned no posts for this page. Check your tags or API credentials."); return; } this.runViewer(); } catch (error) { alert(`Gelbooru Suite Error: Failed to fetch initial data from API. ${error.message}`); Logger.error(error); } finally { viewerButton.textContent = 'Open Deck'; } }); } }, loadBookmarks: async function() { try { const savedBookmarks = await GM.getValue(Config.STORAGE_KEYS.DECK_BOOKMARKS, '[]'); State.bookmarks = JSON.parse(savedBookmarks); } catch (e) { Logger.error("Gelbooru Suite: Could not load or parse bookmarks.", e); State.bookmarks = []; } }, saveBookmarks: async function() { try { const jsonString = JSON.stringify(State.bookmarks); await GM.setValue(Config.STORAGE_KEYS.DECK_BOOKMARKS, jsonString); } catch (e) { Logger.error("Gelbooru Suite: Failed to save bookmarks.", e); } }, toggleBookmark: function() { const post = State.galleryData.posts[State.currentIndex]; if (!post) return; const postId = Utils.getPostId(post.postUrl); const bookmarkIndex = State.bookmarks.findIndex(b => b.id === postId); if (bookmarkIndex > -1) { State.bookmarks.splice(bookmarkIndex, 1); } else { State.bookmarks.push({ id: postId, postUrl: post.postUrl, thumbUrl: post.thumbUrl }); } this.updateUIAfterBookmarkChange(); }, removeBookmark: function(postId) { const bookmarkIndex = State.bookmarks.findIndex(b => b.id === postId); if (bookmarkIndex > -1) { State.bookmarks.splice(bookmarkIndex, 1); } this.updateUIAfterBookmarkChange(); }, updateUIAfterBookmarkChange: function() { UI.renderBookmarkedThumbnails(); UI.updateBookmarkButtonState(); UI.updateBookmarkCounter(); this.saveBookmarks(); }, _handlePageTransition: async function(isPrev) { UI.showLoadingMessage('Loading ' + (isPrev ? 'previous' : 'next') + ' page...'); const newIndex = await API.fetchPage(isPrev); if (newIndex !== null) { this.loadMedia(newIndex); } }, loadMedia: async function(index) { if (index >= State.galleryData.posts.length) { await this._handlePageTransition(false); return; } if (index < 0) { await this._handlePageTransition(true); return; } const post = State.galleryData.posts[index]; State.lastNavigationDirection = index > State.currentIndex ? 1 : (index < State.currentIndex ? -1 : State.lastNavigationDirection || 1); if (post.isBroken) { this.loadMedia(index + State.lastNavigationDirection); return; } State.currentIndex = index; this.manageMemory(); if (post.mediaUrl && post.tags) { UI.displayMedia(post.mediaUrl, post.type, post.tags); } else { const loadingEl = UI.mainContainer.querySelector('.loading-text'); if (loadingEl) { loadingEl.textContent = "Loading..."; loadingEl.style.display = 'block'; } try { const { promise } = Utils.makeRequest({ method: "GET", url: post.postUrl }); const result = await API.getPostData(promise); post.mediaUrl = result.contentUrl; post.tags = result.tags; if (State.currentIndex === index) { UI.displayMedia(post.mediaUrl, post.type, post.tags); } } catch (error) { Logger.error(error.message); post.isBroken = true; if (State.currentIndex === index) { this.loadMedia(State.currentIndex + State.lastNavigationDirection); } } } UI.updateThumbnails(); this.preloadMedia(State.currentIndex); }, preloadMedia: function(centerIndex) { const aheadLimit = Math.min(centerIndex + 1 + State.settings.DECK_PRELOAD_AHEAD, State.galleryData.posts.length); for (let i = centerIndex + 1; i < aheadLimit; i++) { this.fetchAndCache(i); } const behindLimit = Math.max(0, centerIndex - State.settings.DECK_PRELOAD_BEHIND); for (let i = centerIndex - 1; i >= behindLimit; i--) { this.fetchAndCache(i); } }, fetchAndCache: async function(index) { const post = State.galleryData.posts[index]; if (post && (!post.mediaUrl || !post.tags) && !post.isBroken) { try { const { promise } = Utils.makeRequest({ method: "GET", url: post.postUrl }); const result = await API.getPostData(promise); post.mediaUrl = result.contentUrl; post.tags = result.tags; if (result.type === 'image') { new Image().src = result.contentUrl; } UI.updateThumbnails(); } catch (error) { post.isBroken = true; UI.updateThumbnails(); Logger.error(`Failed to pre-cache post ${index}: ${error.message}`); } } }, retryFetch: async function(index) { const post = State.galleryData.posts[index]; if (!post || !post.isBroken) return; Logger.log(`Retrying fetch for post index: ${index}`); post.isBroken = false; post.mediaUrl = null; post.tags = null; post.type = null; const thumb = document.querySelector(`.thumb-container[data-index="${index}"]`); if (thumb) { thumb.classList.remove('broken-thumb'); thumb.classList.add('loading'); const retryIcon = thumb.querySelector('.retry-icon'); if (retryIcon) retryIcon.style.display = 'none'; } await this.fetchAndCache(index); if (thumb) { thumb.classList.remove('loading'); } }, manageMemory: function() { const centerIndex = State.currentIndex; const buffer = 2; const lowerBound = centerIndex - (State.settings.DECK_PRELOAD_BEHIND * buffer); const upperBound = centerIndex + (State.settings.DECK_PRELOAD_AHEAD * buffer); State.galleryData.posts.forEach((post, index) => { if (post.mediaUrl && (index < lowerBound || index > upperBound)) { post.mediaUrl = null; post.tags = null; } }); }, getCurrentPageNum: function() { try { const url = new URL(State.galleryData.baseUrl); const pid = parseInt(url.searchParams.get('pid'), 10) || 0; return Math.floor(pid / Config.DECK_CONSTANTS.POSTS_PER_PAGE) + 1; } catch { return 1; } }, updateHistory: function(pageNum) { if (State.navigationHistory[0] === pageNum) { return; } const existingIndex = State.navigationHistory.indexOf(pageNum); if (existingIndex > -1) { State.navigationHistory.splice(existingIndex, 1); } State.navigationHistory.unshift(pageNum); if (State.navigationHistory.length > Config.DECK_CONSTANTS.HISTORY_LENGTH) { State.navigationHistory.pop(); } }, performJump: async function(pageNum) { if (!pageNum || pageNum <= 0 || !State.galleryData.baseUrl) return; App.updateHistory(App.getCurrentPageNum()); const pid = (pageNum - 1) * Config.DECK_CONSTANTS.POSTS_PER_PAGE; const url = new URL(State.galleryData.baseUrl); url.searchParams.set('pid', pid.toString()); State.galleryData.nextPageUrl = url.href; UI.showLoadingMessage('Jumping to page ' + pageNum + '...'); const newIndex = await API.fetchPage(false); if (newIndex !== null) { App.updateHistory(pageNum); App.loadMedia(newIndex); } }, handleDeckKeyDown: function(e) { const jumpOverlay = document.getElementById('jump-box-overlay'); const settingsOverlay = document.getElementById(`${Config.SELECTORS.SETTINGS_MODAL_ID}-overlay`); const bookmarksOverlay = document.getElementById('bookmarks-overlay'); const key = e.key; const deckHotkeys = { prev: State.settings.KEY_DECK_PREV_MEDIA, next: State.settings.KEY_DECK_NEXT_MEDIA, jump: State.settings.KEY_DECK_JUMP_BOX, bookmark: State.settings.KEY_DECK_TOGGLE_BOOKMARK, close: State.settings.KEY_DECK_CLOSE_PANELS, }; const isPopupOpen = (jumpOverlay && !jumpOverlay.classList.contains('hidden')) || (settingsOverlay && settingsOverlay.style.display !== 'none') || (bookmarksOverlay && !bookmarksOverlay.classList.contains('hidden')); if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') { if (key === deckHotkeys.close) { UI.toggleJumpBox(false); } return; } if (key === deckHotkeys.close) { e.preventDefault(); if (isPopupOpen) { UI.toggleJumpBox(false); UI.closeSettingsModal(); UI.toggleBookmarksPanel(false); } else { this.closeViewer(); } return; } if (isPopupOpen) return; if (key === deckHotkeys.prev) { e.preventDefault(); App.loadMedia(State.currentIndex - 1); } else if (key === deckHotkeys.next) { e.preventDefault(); App.loadMedia(State.currentIndex + 1); } else if (key === deckHotkeys.jump) { e.preventDefault(); UI.toggleJumpBox(true); } else if (key === deckHotkeys.bookmark) { e.preventDefault(); App.toggleBookmark(); } }, async init() { await Settings.load(); UI.addGlobalStyle(); GM_registerMenuCommand('Suite Settings', () => UI.openSettingsModal()); window.addEventListener('pagehide', this.cleanupAllFeatures.bind(this)); this.runAdvancedSearch(); 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'); if (isGalleryPage) { this.setupGalleryNavigation(); this.runGalleryPageEnhancements(); if (State.settings.ENABLE_PEEK_PREVIEWS) { document.querySelectorAll(Config.SELECTORS.THUMBNAIL_GRID_SELECTOR).forEach(this.initializeThumbnailFeatures.bind(this)); const observer = new MutationObserver((mutations) => { 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 }); } } if (isPostPage && State.settings.ENABLE_PEEK_LIGHTBOX) { Object.assign(contentElement.style, { cursor: 'zoom-in' }); UI.createLightboxForContent(); } } }; // ================================================================================= // SCRIPT ENTRY POINT // ================================================================================= if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', App.init.bind(App)); } else { App.init(); } })();