Enhances the functionality of Nhentai website.
// ==UserScript== // @name Nhentai Plus+ // @namespace github.com/longkidkoolstar // @version 10.5.0 // @description Enhances the functionality of Nhentai website. // @author longkidkoolstar // @match https://nhentai.net/* // @require https://code.jquery.com/jquery-3.6.0.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/libs/lz-string.min.js // @icon https://i.imgur.com/AOs1HMS.png // @license MIT // @grant GM.setValue // @grant GM.getValue // @grant GM.addStyle // @grant GM.deleteValue // @grant GM.openInTab // @grant GM.listValues // @grant GM.xmlHttpRequest // ==/UserScript== //----------------------- **Change Log** ------------------------------------------ const CURRENT_VERSION = "10.5.0"; const CHANGELOG_URL = "https://raw.githubusercontent.com/longkidkoolstar/Nhentai-Plus/refs/heads/main/changelog.json"; (async () => { // Check for forced updates from server checkForForcedUpdate(); const lastSeenVersion = await GM.getValue("lastSeenVersion", "0.0.0"); if (CURRENT_VERSION !== lastSeenVersion) { try { const res = await fetch(CHANGELOG_URL); const changelogData = await res.json(); const log = changelogData[CURRENT_VERSION]; if (log) { const msg = `🆕 Version ${CURRENT_VERSION} (${log.date})\n\n` + log.changes.map(line => `• ${line}`).join("\n"); showChangelogPopup(msg); } // Purge old taxonomy keys from GM storage on version update try { await GM.deleteValue('taxonomyCompressed'); await GM.deleteValue('taxonomyUpdatedAt'); await GM.deleteValue('taxonomyLastFetch'); } catch (e) { console.warn('Taxonomy: GM purge failed', e); } await GM.setValue("lastSeenVersion", CURRENT_VERSION); } catch (err) { console.error("Error fetching or displaying changelog:", err); } } })(); function showChangelogPopup(message) { const popup = document.createElement("div"); popup.style.position = "fixed"; popup.style.bottom = "20px"; popup.style.right = "20px"; popup.style.maxWidth = "350px"; popup.style.backgroundColor = "#1e1e1e"; popup.style.color = "#fff"; popup.style.padding = "16px"; popup.style.borderRadius = "8px"; popup.style.boxShadow = "0 4px 12px rgba(0,0,0,0.5)"; popup.style.zIndex = 99999; popup.style.fontFamily = "'Segoe UI', Arial, sans-serif"; popup.style.opacity = "0"; popup.style.transform = "translateY(20px)"; popup.style.transition = "opacity 0.3s ease, transform 0.3s ease"; popup.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <strong style="font-size: 16px;">Changelog</strong> <button style="background: transparent; color: #aaa; border: none; cursor: pointer; font-size: 18px; line-height: 1; padding: 0;">×</button> </div> <pre style="white-space: pre-wrap; font-size: 13px; line-height: 1.4; margin: 0; color: #ddd;">${message}</pre> `; popup.querySelector("button").addEventListener("click", () => { popup.style.opacity = "0"; popup.style.transform = "translateY(20px)"; setTimeout(() => popup.remove(), 300); }); document.body.appendChild(popup); // Trigger animation setTimeout(() => { popup.style.opacity = "1"; popup.style.transform = "translateY(0)"; }, 10); // Auto-dismiss after 15 seconds setTimeout(() => { if (document.body.contains(popup)) { popup.style.opacity = "0"; popup.style.transform = "translateY(20px)"; setTimeout(() => popup.remove(), 300); } }, 15000); } /** * Checks server for forced update requirements * Optimized for incognito: caches check status for 1 hour in session storage */ async function checkForForcedUpdate() { try { // Use sessionStorage as it's per-tab/session and handles incognito well const now = Date.now(); const lastCheck = parseInt(sessionStorage.getItem('nhp_last_update_check') || '0'); const ONE_HOUR = 60 * 60 * 1000; // Only check if it's been more than an hour or if never checked in this session if (now - lastCheck < ONE_HOUR) { console.log("Forced update check skipped (last check < 1h ago)"); return; } const res = await fetch('https://nhentai-share.babykoolstar.workers.dev/status'); const data = await res.json(); // Update last check time regardless of result to prevent spamming the server sessionStorage.setItem('nhp_last_update_check', now.toString()); if (data.forceUpdate && data.minVersion) { if (isVersionOlder(CURRENT_VERSION, data.minVersion)) { console.log("Forced update required!"); forceUpdateLoop(data.message); } else { console.log("Version check passed:", CURRENT_VERSION); } } } catch (error) { console.error("Error checking version status:", error); } } function isVersionOlder(current, min) { const p1 = current.split('.').map(Number); const p2 = min.split('.').map(Number); for (let i = 0; i < Math.max(p1.length, p2.length); i++) { const v1 = p1[i] || 0; const v2 = p2[i] || 0; if (v1 < v2) return true; if (v1 > v2) return false; } return false; } function forceUpdateLoop(msg) { if (document.hasFocus()) { if (confirm(msg || 'A critical update is required. Click OK to update now.')) { window.open('https://greasyfork.org/en/scripts/498553-nhentai-plus', '_blank'); window.location.href = 'https://greasyfork.org/en/scripts/498553-nhentai-plus'; } } // Recursive call to keep displaying the popup setTimeout(() => forceUpdateLoop(msg), 1000); } //----------------------- **Change Log** ------------------------------------------ //----------------------- **Fix Menu OverFlow**---------------------------------- // Nhentai Plus+.user.js $(document).ready(async function() { // Remove the 'required' attribute from the search input when must-add tags are enabled const searchInput = document.querySelector('form.search input[name="q"]'); if (searchInput) { const mustAddTagsEnabled = await GM.getValue('mustAddTagsEnabled', false); const mustAddTags = (await GM.getValue('mustAddTags', [])).map(tag => tag.toLowerCase()); if (mustAddTagsEnabled && mustAddTags.length > 0) { searchInput.removeAttribute('required'); } } var styles = ` @media (max-width: 644px) { nav .collapse.open { max-height: 600px; } } `; $("<style>").html(styles).appendTo("head"); }); //--------------------------**Fix Menu OverFlow**------------------------------------ /** * Detects and removes old-format cache entries while preserving important data */ async function cleanupOldData() { console.log("Starting cleanup of old format entries..."); const allKeys = await GM.listValues(); let removedCount = 0; // Find and delete old manga_URL_ID format keys const oldMangaKeys = allKeys.filter(key => key.startsWith('manga_http')); for (const key of oldMangaKeys) { await GM.deleteValue(key); removedCount++; } // Find and handle URL to title mappings (old format bookmarks) for (const key of allKeys) { // Skip keys that are part of the new format or important lists if (key === 'bookmarkedPages' || key === 'bookmarkedMangas' || key.startsWith('manga_') || key.startsWith('bookmark_manga_ids_')) { continue; } // Check if it's an old-style URL to title mapping const value = await GM.getValue(key); if (typeof value === 'string' && (value.startsWith('Tag: ') || value.startsWith('Search: ') || value.startsWith('Artist: ') || value.startsWith('Character: ') || value.startsWith('Group: ') || value.startsWith('Parody: '))) { // This is an old-style bookmark title, safe to remove await GM.deleteValue(key); removedCount++; } } // Get all stored keys const storedKeys = await GM.listValues(); // Filter keys that match the old title storage format const oldTitleKeys = storedKeys.filter(key => key.startsWith('title_')); // Delete each old title key for (const key of oldTitleKeys) { await GM.deleteValue(key); console.log(`Deleted old title storage key: ${key}`); removedCount++; } console.log(`Cleanup complete! Removed ${removedCount} old format entries.`); return removedCount; } cleanupOldData(); /** * Detects and removes old-format cache entries while preserving important data */ //------------------------ **Find Similar Button** ------------------ // Initialize maxTagsToSelect from localStorage or default to 5 let maxTagsToSelect = GM.getValue('maxTagsToSelect'); if (maxTagsToSelect === undefined) { maxTagsToSelect = 5; GM.setValue('maxTagsToSelect', maxTagsToSelect); } else { maxTagsToSelect = parseInt(maxTagsToSelect); // Ensure it's parsed as an integer } // Array to store locked tags const lockedTags = []; // Function to create and insert 'Find Similar' button async function createFindSimilarButton() { const findSimilarEnabled = await GM.getValue('findSimilarEnabled', true); if (!findSimilarEnabled) return; if (isNaN(maxTagsToSelect)) { maxTagsToSelect = await GM.getValue('maxTagsToSelect'); if (maxTagsToSelect === undefined) { maxTagsToSelect = 5; GM.setValue('maxTagsToSelect', maxTagsToSelect); } } const downloadButton = document.getElementById('download'); if (!downloadButton) { console.log('Download button not found.'); return; } const findSimilarButtonHtml = ` <a class="btn btn-primary btn-disabled tooltip find-similar"> <i class="fas fa-search"></i> <span>Find Similar</span> <div class="top">Click to find similar hentai<i></i></div> <div id="lockedTagsCount">Locked tags: ${lockedTags.length}</div> </a> `; const findSimilarButton = $(findSimilarButtonHtml); // Insert 'Find Similar' button next to the download button // Find the "Find Alt." button const findAltButton = document.querySelector('a.btn.btn-primary.btn-disabled.tooltip.find-similar'); // Insert 'Find Similar' button next to the "Find Alt." button if (findAltButton && downloadButton) { $(findAltButton).after(findSimilarButton); } else { console.log('Download button or Find Alt. button not found.'); } $('#lockedTagsCount').hide(); // Handle click event for 'Find Similar' button findSimilarButton.click(async function() { const tagsContainer = $('div.tag-container.field-name:contains("Tags:")'); if (!tagsContainer.length) { console.log('Tags container not found.'); return; } // Find all tag links within the container const tagLinks = tagsContainer.find('a.tag'); // Update locked tags counter if (!tagLinks.length) { console.log('No tag links found.'); return; } // Extract tag data (name and count) and assign probabilities based on count const tagsData = Array.from(tagLinks).map(tagLink => { const tagName = $(tagLink).find('.name').text().trim(); const tagCount = parseInt($(tagLink).find('.count').text().replace('K', '')) || 0; const probability = Math.sqrt(tagCount); // Adjust this formula as needed return { name: tagName, count: tagCount, probability: probability }; }); await addKnownMultiwordTags(tagsData.map(t => t.name)); // Shuffle tag data array to randomize selection shuffleArray(tagsData); const selectedTags = []; let numTagsSelected = 0; // Add locked tags to the selected tags array lockedTags.forEach(tag => { selectedTags.push(tag); numTagsSelected++; }); tagsData.forEach(tag => { if (numTagsSelected < maxTagsToSelect && !lockedTags.includes(tag.name) && Math.random() < tag.probability) { selectedTags.push(tag.name); numTagsSelected++; } }); // Join selected tag names into a search string const searchTags = selectedTags.map(tag => 'tag:"' + tag + '"').join(' '); const findSimilarType = await GM.getValue('findSimilarType', 'immediately'); const searchInput = $('input[name="q"]'); if (findSimilarType === 'immediately') { if (searchInput.length > 0) { // Update search input value with selected tags searchInput.val(searchTags); } else { // If search input not found, create and submit a hidden form const hiddenSearchFormHtml = ` <form role="search" action="/search/" method="GET" style="display: none;"> <input type="hidden" name="q" value="${searchTags}" /> </form> `; const hiddenSearchForm = $(hiddenSearchFormHtml); $('body').append(hiddenSearchForm); hiddenSearchForm.submit(); } // Submit the form $('button[type="submit"]').click(); } else if (findSimilarType === 'input-tags') { if (searchInput.length > 0) { // Update search input value with selected tags searchInput.val(searchTags); } else { // If search input not found, create a hidden input const hiddenSearchInputHtml = ` <input type="hidden" name="q" value="${searchTags}" /> `; const hiddenSearchInput = $(hiddenSearchInputHtml); $('body').append(hiddenSearchInput); } } // Create and display the slider (only once) if (!$('#tagSlider').length) { createSlider(); } }); // Handle double-click event for 'Find Similar' button findSimilarButton.dblclick(async function() { const searchTags = lockedTags.join(' '); const searchInput = $('input[name="q"]'); if (searchInput.length > 0) { // Update search input value with locked tags only searchInput.val(searchTags); } else { // If search input not found, create and submit a hidden form with locked tags only const hiddenSearchFormHtml = ` <form role="search" action="/search/" method="GET" style="display: none;"> <input type="hidden" name="q" value="${searchTags}" /> </form> `; const hiddenSearchForm = $(hiddenSearchFormHtml); $('body').append(hiddenSearchForm); hiddenSearchForm.submit(); } // Create and display the slider (only once) if (!$('#tagSlider').length) { createSlider(); } }); } // Function to create and display the slider async function createSlider() { const sliderHtml = ` <div style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;"> <input type="range" min="1" max="10" value="${maxTagsToSelect}" id="tagSlider"> <label for="tagSlider">Max Tags to Select: <span id="tagSliderValue">${maxTagsToSelect}</span></label> </div> `; $(document.body).append(sliderHtml); // Retrieve saved maxTagsToSelect value from GM storage (if available) const savedMaxTags = await GM.getValue('maxTagsToSelect'); if (savedMaxTags !== undefined) { maxTagsToSelect = parseInt(savedMaxTags); $('#tagSlider').val(maxTagsToSelect); $('#tagSliderValue').text(maxTagsToSelect); } // Update maxTagsToSelect based on slider value and save to GM storage $('#tagSlider').on('input', async function() { maxTagsToSelect = parseInt($(this).val()); $('#tagSliderValue').text(maxTagsToSelect); // Store the updated maxTagsToSelect value in GM storage await GM.setValue('maxTagsToSelect', maxTagsToSelect); }); } // Call the function to create 'Find Similar' button createFindSimilarButton(); function updateLockedTagsCounter() { const lockedTagsCount = lockedTags.length; const lockedTagsCounter = $('#lockedTagsCount'); if (lockedTagsCount > 0) { lockedTagsCounter.text(`Locked tags: ${lockedTagsCount}`).show(); if (lockedTagsCount > maxTagsToSelect) { lockedTagsCounter.css('color', 'red'); } else { lockedTagsCounter.css('color', ''); // Reset color to default } } else { lockedTagsCounter.hide(); } } // Function to toggle lock buttons based on findSimilarEnabled async function toggleLockButtons() { const findSimilarEnabled = await GM.getValue('findSimilarEnabled', true); if (findSimilarEnabled) { $('span.lock-button').show(); } else { $('span.lock-button').hide(); } } // Event listener for locking/unlocking tags $(document).on('click', 'span.lock-button', function(event) { event.stopPropagation(); // Prevent tag link click event from firing const tagName = $(this).prev('a.tag').find('.name').text().trim(); if (lockedTags.includes(tagName)) { // Tag is already locked, unlock it const index = lockedTags.indexOf(tagName); if (index !== -1) { lockedTags.splice(index, 1); } $(this).html('<i class="fas fa-plus"></i>'); // Change icon to plus updateLockedTagsCounter(); } else { // Lock the tag lockedTags.push(tagName); $(this).html('<i class="fas fa-minus"></i>'); // Change icon to minus updateLockedTagsCounter(); } }); // Add lock button next to each tag const tagsContainer = $('div.tag-container.field-name:contains("Tags:")'); if (tagsContainer.length) { const tagLinks = tagsContainer.find('a.tag'); tagLinks.each(function(index, tagLink) { const lockButtonHtml = ` <span class="lock-button" data-tag-index="${index}"> <i class="fas fa-plus"></i> </span> `; const lockButton = $(lockButtonHtml); $(tagLink).after(lockButton); }); } // Initialize lock buttons visibility based on findSimilarEnabled toggleLockButtons(); console.log('Script setup complete.'); // Function to shuffle an array (Fisher-Yates shuffle algorithm) function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } } //------------------------ **Find Similar Button** ------------------ //----------------------- **Find Alternative Manga Button** ------------------ // Adds a button to the page that allows the user to find alternative manga to the current one. // Checks if the feature is enabled in the settings before appending the button. async function addFindAltButton() { const findAltmangaEnabled = await GM.getValue('findAltmangaEnabled', true); if (!findAltmangaEnabled) return; // Get the download button const downloadButton = document.getElementById('download'); if (!downloadButton) { console.log('Download button not found.'); return; } const copyTitleButtonHtml = ` <a class="btn btn-primary btn-disabled tooltip find-similar"> <i class="fas fa-code-branch"></i> <span>Find Alt.</span> <div class="top">Click to find alternative manga to this one<i></i></div> </a> `; const copyTitleButton = $(copyTitleButtonHtml); // Handle click event for the button copyTitleButton.click(function() { // Get the title element const titleElement = $('h1.title'); if (!titleElement.length) { console.log('Title element not found.'); return; } // Extract the text content from the pretty class if it exists let titleText; const prettyElement = titleElement.find('.pretty'); if (prettyElement.length) { titleText = prettyElement.text(); } else { titleText = titleElement.text(); } // Remove text inside square brackets [], parentheses (), 'Ch.', 'ch.', 'Vol.', 'vol.', and all Chinese and Japanese characters const cleanedTitleText = titleText.replace(/[\[\]\(\)]|Ch\.|ch\.|Vol\.|vol\.|Ep\.|Ep|ep\.|ep|\|[\u3002\uFF01-\uFF5E\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF]|(?<!\w)-(?!\w)|\d+/g, '').trim(); // Find the search input const searchInput = $('input[name="q"]'); if (searchInput.length > 0) { // Update search input value with cleaned title text searchInput.val(cleanedTitleText); // Bypass Smart Tags: navigate directly to search with raw cleaned title window.location.href = `/search/?q=${encodeURIComponent(cleanedTitleText)}`; } else { console.log('Search input not found.'); } }); // Insert 'Find Similar' button next to the download button $(downloadButton).after(copyTitleButton); } // Call the function to add the Copy Title button addFindAltButton(); //------------------------ **Find Alternative Manga Button** ------------------ //------------------------ **Find Alternative Manga Button(Thumbnail Version)** ------------------ (async function() { const findAltMangaThumbnailEnabled = await GM.getValue('findAltMangaThumbnailEnabled', true); // Default to true if not set if (!findAltMangaThumbnailEnabled) return; // Exit if the feature is not enabled const flagEn = "https://i.imgur.com/vSnHmmi.gif"; const flagJp = "https://i.imgur.com/GlArpuS.gif"; const flagCh = "https://i.imgur.com/7B55DYm.gif"; const non_english_fade_opacity = 0.3; const partially_fade_all_non_english = true; const mark_as_read_system_enabled = true; const marked_as_read_fade_opacity = 0.3; const auto_group_on_page_comics = true; const version_grouping_filter_brackets = false; let MARArray = []; GM.getValue("MARArray", "[]").then((value) => { if (typeof value === 'string') { MARArray = JSON.parse(value); } GM.addStyle(` .overlayFlag { position: absolute; display: inline-block; top: 3px; left: 3px; z-index: 3; width: 18px; height: 12px; } .numOfVersions { border-radius: 10px; padding: 5px 10px; position: absolute; background-color: rgba(0,0,0,.7); color: rgba(255,255,255,.8); top: 7.5px; left: 105px; font-size: 12px; font-weight: 900; opacity: 1; width: 40px; z-index: 2; display: none; } .findVersionButton { border-radius: 10px; padding: 5px 10px; position: absolute; background-color: rgba(0,0,0,.4); color: rgba(255,255,255,.8); bottom: 7.5px; left: 7.5px; font-size: 12px; font-weight: 900; opacity: 1; width: 125px; z-index: 2; cursor: pointer; } .versionNextButton { border-radius: 10px; padding: 5px 10px; position: absolute; background-color: rgba(0,0,0,.7); color: rgba(255,255,255,.8); top: 7.5px; right: 7.5px; font-size: 12px; font-weight: 900; opacity: 1; display: none; z-index: 2; cursor: pointer; } .versionPrevButton { border-radius: 10px; padding: 5px 10px; position: absolute; background-color: rgba(0,0,0,.7); color: rgba(255,255,255,.8); top: 7.5px; left: 7.5px; font-size: 12px; font-weight: 900; opacity: 1; z-index: 2; display: none; cursor: pointer; } .newTabButton { border-radius: 10px; padding: 5px 10px; position: absolute; background-color: rgba(0,0,0,.4); color: rgba(255,255,255,.8); bottom: 7.5px; right: 7.5px; /* Position on the right side */ font-size: 12px; font-weight: 900; opacity: 1; width: auto; /* Smaller width since text is shorter */ z-index: 4; cursor: pointer; text-align: center; } /* Add hover effect */ .newTabButton:hover { background-color: rgba(0,0,0,.7); } `); function IncludesAll(string, search) { string = CleanupSearchString(string); search = CleanupSearchString(search); if (string.length == 0 || search.length == 0) return false; let searches = search.split(" "); for (let i = 0; i < searches.length; i++) { if (!!searches[i] && searches[i].length > 0 && !string.includes(searches[i])) return false; } return true; } async function AddAltVersionsToThis(target) { let place = target; const coverElement = place.parent().find(".cover:visible"); const href = coverElement.attr('href'); const captionTitle = place.parent().find(".cover:visible > .caption").text(); try { let titles = [captionTitle]; // Start with the caption title // Try to get the title from the manga page if href exists if (href) { try { const response = await fetch(`https://nhentai.net${href}`); if (response.ok) { const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const titleElement = doc.querySelector('.title'); if (titleElement) { const prettySpan = titleElement.querySelector('.pretty'); let titleText = prettySpan ? prettySpan.textContent.trim() : titleElement.textContent.trim(); const cleanedTitleText = titleText.replace(/[\[\]\(\)]|Ch\.|ch\.|Vol\.|vol\.|Ep\.|Ep|ep\.|ep|\|[\u3002\uFF01-\uFF5E\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF]|(?<!\w)-(?!\w)|\d+/g, '').trim(); // Add the cleaned title if it's different from the caption title if (cleanedTitleText && cleanedTitleText !== captionTitle) { titles.push(cleanedTitleText); } } } } catch (error) { console.error("Error fetching title from manga page:", error); } } // Process search with all collected titles await processSearchWithMultipleTitles(titles); } catch (error) { console.error("Error in AddAltVersionsToThis:", error); // Fallback to just the caption title if there's an error processSearch(captionTitle); } // Function to process search with multiple titles and combine results async function processSearchWithMultipleTitles(titles) { let allResults = []; let processedHrefs = new Set(); // To track unique results for (const title of titles) { if (!title || title.trim() === '') continue; try { // Use fetch API instead of jQuery's $.get to avoid XHR interception issues const response = await fetch(BuildUrl(title)); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const text = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const found = doc.querySelectorAll(".container > .gallery"); if (found && found.length > 0) { // Add unique results to allResults for (let i = 0; i < found.length; i++) { const resultHref = found[i].querySelector(".cover")?.getAttribute('href'); if (resultHref && !processedHrefs.has(resultHref)) { processedHrefs.add(resultHref); allResults.push(found[i]); } } } } catch (error) { console.error(`Error searching for title "${title}":`, error); } } if (allResults.length === 0) { alert("No results found for any of the search terms"); return; } // Process the combined results place.parent().find(".cover").remove(); try { for (let i = 0; i < allResults.length; i++) { // Convert DOM element to jQuery object for consistent handling const $result = $(allResults[i]); if (partially_fade_all_non_english) { $result.find(".cover > img, .cover > .caption").css("opacity", non_english_fade_opacity); } const dataTags = $result.attr("data-tags") || ""; if (dataTags.includes("12227")) { $result.find(".caption").append(`<img class="overlayFlag" src="` + flagEn + `">`); $result.find(".cover > img, .cover > .caption").css("opacity", "1"); } else { if (dataTags.includes("6346")) { $result.find(".caption").append(`<img class="overlayFlag" src="` + flagJp + `">`); } else if (dataTags.includes("29963")) { $result.find(".caption").append(`<img class="overlayFlag" src="` + flagCh + `">`); } if (!partially_fade_all_non_english) { $result.find(".cover > img, .cover > .caption").css("opacity", "1"); } } if (mark_as_read_system_enabled) { let MARArraySelector = MARArray.join("'], .cover[href='"); $result.find(".cover[href='" + MARArraySelector + "']").append("<div class='readTag'>READ</div>"); let readTag = $result.find(".readTag"); if (!!readTag && readTag.length > 0) { readTag.parent().parent().find(".cover > img, .cover > .caption").css("opacity", marked_as_read_fade_opacity); } } let thumbnailReplacement; const $img = $result.find(".cover > img"); const dataSrc = $img.attr("data-src"); if (dataSrc) { thumbnailReplacement = dataSrc .replace(/\/\/.+?\.nhentai/g, "//i1.nhentai") // Fixed CDN path .replace("thumb.", "1."); // Generic replacement for all extensions } else { thumbnailReplacement = $img.attr("src") .replace(/\/\/.+?\.nhentai/g, "//i1.nhentai") // Fixed CDN path .replace("thumb.", "1."); // Generic replacement for all extensions } $img.attr("src", thumbnailReplacement); place.parent().append($result.find(".cover")); } } catch (er) { alert("Error modifying data: " + er); return; } place.parent().find(".cover:not(:first)").css("display", "none"); place.parent().find(".versionPrevButton, .versionNextButton, .numOfVersions").show(200); place.parent().find(".numOfVersions").text("1/" + (allResults.length)); place.hide(200); } // Original search function as fallback async function processSearch(title) { try { // Use fetch API instead of jQuery's $.get to avoid XHR interception issues const response = await fetch(BuildUrl(title)); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const text = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const foundElements = doc.querySelectorAll(".container > .gallery"); if (!foundElements || foundElements.length <= 0) { alert("Error reading data"); return; } // Convert NodeList to jQuery collection for easier manipulation const found = $(foundElements); place.parent().find(".cover").remove(); try { for (let i = 0; i < found.length; i++) { const $item = $(found[i]); if (partially_fade_all_non_english) { $item.find(".cover > img, .cover > .caption").css("opacity", non_english_fade_opacity); } const dataTags = $item.attr("data-tags") || ""; if (dataTags.includes("12227")) { $item.find(".caption").append(`<img class="overlayFlag" src="` + flagEn + `">`); $item.find(".cover > img, .cover > .caption").css("opacity", "1"); } else { if (dataTags.includes("6346")) { $item.find(".caption").append(`<img class="overlayFlag" src="` + flagJp + `">`); } else if (dataTags.includes("29963")) { $item.find(".caption").append(`<img class="overlayFlag" src="` + flagCh + `">`); } if (!partially_fade_all_non_english) { $item.find(".cover > img, .cover > .caption").css("opacity", "1"); } } if (mark_as_read_system_enabled) { let MARArraySelector = MARArray.join("'], .cover[href='"); $item.find(".cover[href='" + MARArraySelector + "']").append("<div class='readTag'>READ</div>"); let readTag = $item.find(".readTag"); if (!!readTag && readTag.length > 0) { readTag.parent().parent().find(".cover > img, .cover > .caption").css("opacity", marked_as_read_fade_opacity); } } let thumbnailReplacement; const $img = $item.find(".cover > img"); const dataSrc = $img.attr("data-src"); if (dataSrc) { thumbnailReplacement = dataSrc .replace(/\/\/.+?\.nhentai/g, "//i1.nhentai") // Fixed CDN path .replace("thumb.", "1."); // Generic replacement for all extensions } else { thumbnailReplacement = $img.attr("src") .replace(/\/\/.+?\.nhentai/g, "//i1.nhentai") // Fixed CDN path .replace("thumb.", "1."); // Generic replacement for all extensions } $img.attr("src", thumbnailReplacement); place.parent().append($item.find(".cover")); } } catch (er) { alert("Error modifying data: " + er); return; } place.parent().find(".cover:not(:first)").css("display", "none"); place.parent().find(".versionPrevButton, .versionNextButton, .numOfVersions").show(200); place.parent().find(".numOfVersions").text("1/" + (found.length)); place.hide(200); } catch (e) { alert("Error getting data: " + e); } } } function CleanupSearchString(title) { title = title.replace(/\[.*?\]/g, ""); title = title.replace(/\【.*?\】/g, ""); if (version_grouping_filter_brackets) title = title.replace(/\(.*?\)/g, ""); return title.trim(); } function BuildUrl(title) { let url = CleanupSearchString(title); url = url.trim(); url = url.replace(/(^|\s){1}[^\w\s\d]{1}(\s|$){1}/g, " "); // remove all instances of a lone symbol character url = url.replace(/\s+/g, '" "'); // wrap all terms with "" url = '"' + url + '"'; url = encodeURIComponent(url); url = "https://nhentai.net/search/?q=" + url; return url; } async function GroupAltVersionsOnPage() { // Check if the feature is enabled const mangagroupingenabled = await GM.getValue('mangagroupingenabled', true); if (!mangagroupingenabled) return; let i = 0; let found = $(".container > .gallery"); while (!!found && i < found.length) { AddAltVersionsToThisFromPage(found[i]); i++; found = $(".container > .gallery"); } } function AddAltVersionsToThisFromPage(target) { let place = $(target); place.addClass("ignoreThis"); let title = place.find(".cover > .caption").text(); if (!title || title.length <= 0) return; let found = $(".container > .gallery:not(.ignoreThis)"); let numOfValid = 0; for (let i = 0; i < found.length; i++) { let cap = $(found[i]).find(".caption"); if (cap.length == 1) { if (IncludesAll(cap.text(), title)) { if (partially_fade_all_non_english) { $(found[i]).find(".cover > img, .cover > .caption").css("opacity", non_english_fade_opacity); } if ($(found[i]).attr("data-tags").includes("12227")) { $(found[i]).find(".caption").append(`<img class="overlayFlag" src="` + flagEn + `">`); $(found[i]).find(".cover > img, .cover > .caption").css("opacity", "1"); } else { if ($(found[i]).attr("data-tags").includes("6346")) { $(found[i]).find(".caption").append(`<img class="overlayFlag" src="` + flagJp + `">`); } else if ($(found[i]).attr("data-tags").includes("29963")) { $(found[i]).find(".caption").append(`<img class="overlayFlag" src="` + flagCh + `">`); } if (!partially_fade_all_non_english) { $(found[i]).find(".cover > img, .cover > .caption").css("opacity", "1"); } } if (mark_as_read_system_enabled) { let MARArraySelector = MARArray.join("'], .cover[href='"); $(found[i]).find(".cover[href='" + MARArraySelector + "']").append("<div class='readTag'>READ</div>"); let readTag = $(found[i]).find(".readTag"); if (!!readTag && readTag.length > 0) { readTag.parent().parent().find(".cover > img, .cover > .caption").css("opacity", marked_as_read_fade_opacity); } } place.append($(found[i]).find(".cover")); $(found[i]).addClass("deleteThis"); numOfValid++; } } else { let addThese = false; for (let j = 0; j < cap.length; j++) { if (IncludesAll($(cap[j]).text(), title)) { addThese = true; break; } } if (addThese) { for (let j = 0; j < cap.length; j++) { place.append($(cap[j]).parent()); } $(found[i]).addClass("deleteThis"); numOfValid += cap.length; } } } numOfValid++; place.removeClass("deleteThis"); place.removeClass("ignoreThis"); $(".deleteThis").remove(); if (numOfValid > 1) { place.find(".cover:not(:first)").css("display", "none"); place.find(".versionPrevButton, .versionNextButton, .numOfVersions").show(200); place.find(".numOfVersions").text("1/" + numOfValid); } } if ($(".container.index-container, #favcontainer.container, #recent-favorites-container, #related-container").length !== 0) { $(".cover").parent().append("<div class='findVersionButton'>Find Alt Versions</div>"); $(".cover").parent().append("<div class='numOfVersions'>1/1</div>"); $(".cover").parent().append("<div class='versionNextButton'>►</div>"); $(".cover").parent().append("<div class='versionPrevButton'>◄</div>"); $(".findVersionButton").click(function(e) { e.preventDefault(); AddAltVersionsToThis($(this)); updateMarkAsReadButtonPosition($(this).closest('.gallery')); // Pass the gallery context }); //------------- UPD CSS BASED ON SETTINGS RQ ----------------------------- // Function to update the position of the mark-as-read button async function updateMarkAsReadButtonPosition(galleryContext) { const markAsReadButton = galleryContext.find('.mark-as-read-btn'); const showPageNumbersEnabled = await GM.getValue('showPageNumbersEnabled', true); console.log(showPageNumbersEnabled); if (showPageNumbersEnabled || $('.findVersionButton').length > 0) { // Check if find alt version button exists markAsReadButton.css('top', '40px'); } else { markAsReadButton.css('top', '5px'); } } // Function to adjust the position of a specific element setInterval(function() { const pageNumberDisplay = $('.page-number-display'); if (pageNumberDisplay.length > 0) { $('.mark-as-read-btn').css('top', '40px'); } }, 1000); // Function to adjust the position of tag warning badges setInterval(function() { if ($('.findVersionButton').length > 0) { $('.tag-warning-badge').css({ 'bottom': '40px', 'left': '10px' }); } }, 100); //------------- UPD CSS BASED ON SETTINGS RQ ----------------------------- if (auto_group_on_page_comics) GroupAltVersionsOnPage(); $(".versionPrevButton").click(function(e) { e.preventDefault(); let toHide = $(this).parent().find(".cover").filter(":visible"); let toShow = toHide.prev(); if (!toShow || toShow.length <= 0) return; if (!toShow.is(".cover")) toShow = toHide.prevUntil(".cover", ":last").prev(); if (!toShow || toShow.length <= 0) return; toHide.hide(100); toShow.show(100); let n = $(this).parent().find(".numOfVersions"); n.text((Number(n.text().split("/")[0]) - 1) + "/" + n.text().split("/")[1]); }); $(".versionNextButton").click(function(e) { e.preventDefault(); let toHide = $(this).parent().find(".cover").filter(":visible"); let toShow = toHide.next(); if (!toShow || toShow.length <= 0) return; if (!toShow.is(".cover")) toShow = toHide.nextUntil(".cover", ":last").next(); if (!toShow || toShow.length <= 0) return; toHide.hide(100); toShow.show(100); let n = $(this).parent().find(".numOfVersions"); n.text((Number(n.text().split("/")[0]) + 1) + "/" + n.text().split("/")[1]); }); } }); })(); // Self-invoking function for the toggle check //------------------------ **Find Alternative Manga Button(Thumbnail Version)** ------------------ // ------------------------ *Bookmarks** ------------------ function injectCSS() { const css = ` /* Bookmark animation */ @keyframes bookmark-animation { 0% { transform: scale(1) rotate(0deg); } 50% { transform: scale(1.2) rotate(20deg); } 100% { transform: scale(1) rotate(0deg); } } /* Add a class for the animation */ .bookmark-animating { animation: bookmark-animation 0.4s ease-in-out; } `; const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(css)); document.head.appendChild(style); } injectCSS(); // Inject the CSS when the userscript runs // Function to create and insert bookmark button async function createBookmarkButton() { // Check if the feature is enabled in settings const bookmarksEnabled = await GM.getValue('bookmarksEnabled', true); if (!bookmarksEnabled) { return; } // Check if the page is already bookmarked const bookmarkedPages = await GM.getValue('bookmarkedPages', []); const currentPage = window.location.href; const isBookmarked = bookmarkedPages.includes(currentPage); // Bookmark button HTML using Font Awesome 5.13.0 const bookmarkButtonHtml = ` <a class="btn btn-primary bookmark-btn" style="margin-left: 10px;"> <i class="bookmark-icon ${isBookmarked ? 'fas' : 'far'} fa-bookmark"></i> </a> `; const bookmarkButton = $(bookmarkButtonHtml); // Append the bookmark button as a child of the h1 element if it exists const h1Element = document.querySelector("#content > h1"); if (h1Element) { h1Element.append(bookmarkButton[0]); } // Handle click event for the bookmark button bookmarkButton.click(async function() { const bookmarkIcon = $(this).find('i.bookmark-icon'); const bookmarkedPages = await GM.getValue('bookmarkedPages', []); const currentPage = window.location.href; const isBookmarked = bookmarkedPages.includes(currentPage); // Add animation class bookmarkIcon.addClass('bookmark-animating'); if (isBookmarked) { // Remove the bookmark const updatedBookmarkedPages = bookmarkedPages.filter(page => page !== currentPage); await GM.setValue('bookmarkedPages', updatedBookmarkedPages); await GM.deleteValue(currentPage); // Get the list of manga IDs for this bookmark const bookmarkMangaIds = await GM.getValue(`bookmark_manga_ids_${currentPage}`, []); // Delete the bookmark's manga ID list await GM.deleteValue(`bookmark_manga_ids_${currentPage}`); // For each manga associated with this bookmark const allKeys = await GM.listValues(); const mangaKeys = allKeys.filter(key => key.startsWith('manga_')); for (const key of mangaKeys) { const mangaInfo = await GM.getValue(key); // If this manga is associated with the deleted bookmark if (mangaInfo && mangaInfo.bookmarks && mangaInfo.bookmarks.includes(currentPage)) { // Remove this bookmark from the manga's bookmarks list mangaInfo.bookmarks = mangaInfo.bookmarks.filter(b => b !== currentPage); // If this manga is no longer in any bookmarks, delete it entirely if (mangaInfo.bookmarks.length === 0) { await GM.deleteValue(key); console.log(`Deleted orphaned manga: ${key}`); } else { // Otherwise, update the manga info with the bookmark removed await GM.setValue(key, mangaInfo); console.log(`Updated manga ${key}: removed bookmark reference`); } } } // Switch icon class to 'far' when unbookmarking bookmarkIcon.addClass('far').removeClass('fas'); } else { // Add the bookmark bookmarkedPages.push(currentPage); await GM.setValue('bookmarkedPages', bookmarkedPages); // Switch icon class to 'fas' when bookmarking bookmarkIcon.addClass('fas').removeClass('far'); } // Remove animation class after animation ends setTimeout(() => { bookmarkIcon.removeClass('bookmark-animating'); }, 400); // Match the duration of the CSS animation (0.4s) }); } // Only execute if not on the settings page or favorites page if (window.location.href.indexOf('nhentai.net/settings') === -1 && window.location.href.indexOf('nhentai.net/favorites') === -1) { createBookmarkButton(); } async function addBookmarkButton() { const bookmarksPageEnabled = await GM.getValue('bookmarksPageEnabled', true); if (!bookmarksPageEnabled) return; // Create the bookmark button const bookmarkButtonHtml = ` <li> <a href="/bookmarks/"> <i class="fa fa-bookmark"></i> Bookmarks </a> </li> `; const bookmarkButton = $(bookmarkButtonHtml); // Append the bookmark button to the dropdown menu const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(bookmarkButton); // Append the bookmark button to the menu const menu = $('ul.menu.left'); menu.append(bookmarkButton); // Call updateMenuOrder to ensure proper tab order setTimeout(updateMenuOrder, 100); } async function addOfflineFavoritesButton() { const offlineFavoritingEnabled = await GM.getValue('offlineFavoritingEnabled', true); const offlineFavoritesPageEnabled = await GM.getValue('offlineFavoritesPageEnabled', true); const isLoggedIn = !document.querySelector('.menu-sign-in'); if (offlineFavoritingEnabled && offlineFavoritesPageEnabled && !isLoggedIn) { // Create the offline favorites button const offlineFavoritesButtonHtml = ` <li> <a href="/favorite/"> <i class="fa fa-heart"></i> Offline Favorites </a> </li> `; const offlineFavoritesButton = $(offlineFavoritesButtonHtml); // Append to dropdown menu const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(offlineFavoritesButton); // Append to main menu const menu = $('ul.menu.left'); menu.append(offlineFavoritesButton); // Call updateMenuOrder to ensure proper tab order setTimeout(updateMenuOrder, 100); } } addBookmarkButton(); // Call the function to add the bookmark button addOfflineFavoritesButton(); // Call the function to add the offline favorites button // Add Read Manga button function async function addReadMangaButton() { const markAsReadEnabled = await GM.getValue('markAsReadEnabled', true); const readMangaPageEnabled = await GM.getValue('readMangaPageEnabled', true); if (markAsReadEnabled && readMangaPageEnabled) { // Check if link already exists if (document.querySelector('a[href="/read-manga/"]')) return; // Create the read manga button const readMangaButtonHtml = ` <li> <a href="/read-manga/"> <i class="fas fa-book-open"></i> Read Manga </a> </li> `; const readMangaButton = $(readMangaButtonHtml); // Append to dropdown menu const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(readMangaButton); // Append to main menu const menu = $('ul.menu.left'); menu.append(readMangaButton); // Add click handler for navigation readMangaButton.find('a').on('click', (e) => { e.preventDefault(); if (window.readMangaPageSystem) { window.readMangaPageSystem.navigateToReadMangaPage(); } }); // Call updateMenuOrder to ensure proper tab order setTimeout(updateMenuOrder, 100); } } addReadMangaButton(); // Call the function to add the read manga button // Delete error message on unsupported bookmarks page (async function() { if (window.location.href.includes('/bookmarks')) { // Remove not found heading const notFoundHeading = document.querySelector('h1'); if (notFoundHeading?.textContent === '404 – Not Found') { notFoundHeading.remove(); } // Remove not found message const notFoundMessage = document.querySelector('p'); if (notFoundMessage?.textContent === "Looks like what you're looking for isn't here.") { notFoundMessage.remove(); } // Function to fetch the title of a webpage with caching and retries async function fetchTitleWithCacheAndRetry(url, retries = 3) { // Check if we have cached manga IDs for this bookmark const mangaIds = await GM.getValue(`bookmark_manga_ids_${url}`, []); // If we have cached manga data, use it to construct the title if (mangaIds.length > 0) { // For bookmarks with multiple manga, we'll show a count if (mangaIds.length > 1) { let itemCount = mangaIds.length; let itemSuffix = itemCount > 25 ? `+` : ``; return `${url} (${itemCount}${itemSuffix} items)`; } // For a single manga, fetch its details else { const mangaId = mangaIds[0]; const mangaInfo = await GM.getValue(`manga_${mangaId}`); if (mangaInfo && mangaInfo.title) { return mangaInfo.title; } } } // If no cached data found, fetch the title directly for (let i = 0; i < retries; i++) { try { const response = await fetch(url); if (response.status === 429) { // If we get a 429, wait for a bit before retrying await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); continue; } const text = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); let title = doc.querySelector('title').innerText; // Remove "» nhentai: hentai doujinshi and manga" from the title const unwantedPart = "» nhentai: hentai doujinshi and manga"; if (title.includes(unwantedPart)) { title = title.replace(unwantedPart, '').trim(); } // We no longer cache the title directly with the URL as the key // Instead, we'll create proper relationships when manga data is saved return title; } catch (error) { console.error(`Error fetching title for: ${url}. Attempt ${i + 1} of ${retries}`, error); if (i === retries - 1) { return url; // Fallback to URL if all retries fail } } } } // Function to display bookmarked pages with active loading for unfetched bookmarks async function displayBookmarkedPages() { let bookmarkedPages = await GM.getValue('bookmarkedPages', []); let bookmarkedMangas = await GM.getValue('bookmarkedMangas', []); const bookmarkArrangementType = await GM.getValue('bookmarkArrangementType', 'default'); if (Array.isArray(bookmarkedPages) && Array.isArray(bookmarkedMangas)) { // Sort bookmarked mangas based on arrangement type if (bookmarkArrangementType === 'alphabetical') { bookmarkedMangas.sort((a, b) => { const titleA = a.title ? a.title.toLowerCase() : ''; const titleB = b.title ? b.title.toLowerCase() : ''; return titleA.localeCompare(titleB); }); } else if (bookmarkArrangementType === 'reverse-alphabetical') { bookmarkedMangas.sort((a, b) => { const titleA = a.title ? a.title.toLowerCase() : ''; const titleB = b.title ? b.title.toLowerCase() : ''; return titleB.localeCompare(titleA); }); } // Get manga IDs for each bookmark page to sort by item count const bookmarkItemCounts = {}; if (bookmarkArrangementType === 'most-items' || bookmarkArrangementType === 'least-items') { // We'll need to fetch the manga counts for each bookmark for (const page of bookmarkedPages) { const mangaIds = await GM.getValue(`bookmark_manga_ids_${page}`, []); bookmarkItemCounts[page] = mangaIds.length; } // Sort bookmarked pages based on item count if (bookmarkArrangementType === 'most-items') { bookmarkedPages.sort((a, b) => bookmarkItemCounts[b] - bookmarkItemCounts[a]); } else if (bookmarkArrangementType === 'least-items') { bookmarkedPages.sort((a, b) => bookmarkItemCounts[a] - bookmarkItemCounts[b]); } } // Note: For default arrangement, we keep the original order (most recent first) const bookmarksContainer = $('<div id="bookmarksContainer" class="container">'); // Create Tabs Navigation const tabsNav = $('<div class="bookmarks-tabs">'); const pagesTabBtn = $('<button class="tab-btn active" data-tab="pages">Bookmarked Pages</button>'); const mangaTabBtn = $('<button class="tab-btn" data-tab="manga">Bookmarked Manga</button>'); tabsNav.append(pagesTabBtn, mangaTabBtn); // Create Pages Tab Content const pagesTabContent = $('<div id="pages-tab" class="tab-content active">'); const bookmarksTitle = $('<h2 class="bookmarks-title">Bookmarked Pages</h2>'); const pageTitleInput = $('<input type="text" id="searchBookmarks" placeholder="Search pages..." class="search-input">'); const pageTagInput = $('<input type="text" id="searchPageTags" placeholder="Search page tags..." class="search-input">'); const bookmarksList = $('<ul class="bookmarks-list">'); pagesTabContent.append(bookmarksTitle, pageTitleInput, pageTagInput, bookmarksList); // Create Manga Tab Content const mangaTabContent = $('<div id="manga-tab" class="tab-content">'); const mangaBookmarksTitle = $('<h2 class="bookmarks-title">Bookmarked Manga</h2>'); const mangaTitleInput = $('<input type="text" id="searchMangaTitle" placeholder="Search manga title..." class="search-input">'); const mangaTagInput = $('<input type="text" id="searchMangaTags" placeholder="Search manga tags..." class="search-input">'); const mangaBookmarksList = $('<ul id="mangaBookmarksList" class="bookmarks-grid">'); mangaTabContent.append(mangaBookmarksTitle, mangaTitleInput, mangaTagInput, mangaBookmarksList); bookmarksContainer.append(tabsNav, pagesTabContent, mangaTabContent); $('body').append(bookmarksContainer); // Tab Switching Logic tabsNav.find('.tab-btn').on('click', function() { const tabId = $(this).data('tab'); // Update buttons $('.tab-btn').removeClass('active'); $(this).addClass('active'); // Update content $('.tab-content').removeClass('active'); $(`#${tabId}-tab`).addClass('active'); }); // Add CSS styles const styles = ` /* Tab Styles */ .bookmarks-tabs { display: flex; margin-bottom: 20px; border-bottom: 2px solid #444; } .tab-btn { background: none; border: none; color: #aaa; padding: 10px 20px; cursor: pointer; font-size: 16px; font-weight: bold; transition: color 0.3s, border-bottom 0.3s; border-bottom: 2px solid transparent; margin-bottom: -2px; } .tab-btn:hover { color: #f1faee; } .tab-btn.active { color: #e63946; border-bottom: 2px solid #e63946; } .tab-content { display: none; animation: fadeIn 0.3s ease; } .tab-content.active { display: block; } @keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } #bookmarksContainer { margin: 20px auto; padding: 20px; background-color: #2c2c2c; border-radius: 8px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); width: 90%; max-width: 1200px; /* Increased max-width for better grid */ } .bookmarks-title { font-size: 24px; margin-bottom: 10px; color: #e63946; } .search-input { width: calc(100% - 20px); padding: 10px; margin-bottom: 20px; border-radius: 5px; border: 1px solid #ccc; font-size: 16px; } .bookmarks-list { list-style: none; padding: 0; max-height: 60vh; /* Limit height relative to viewport */ overflow-y: auto; scrollbar-width: thin; scrollbar-color: #e63946 #2c2c2c; border: 1px solid #444; border-radius: 4px; background: #222; } .bookmarks-list::-webkit-scrollbar { width: 8px; } .bookmarks-list::-webkit-scrollbar-track { background: #2c2c2c; } .bookmarks-list::-webkit-scrollbar-thumb { background-color: #e63946; border-radius: 4px; } .bookmark-link { display: block; padding: 10px; font-size: 18px; color: #f1faee; text-decoration: none; transition: background-color 0.3s, color 0.3s; } .bookmark-link:hover { background-color: #e63946; color: #1d3557; } .delete-button:hover { color: #f1faee; } .bookmark-list-item { display: flex; align-items: center; border-bottom: 1px solid #333; background-color: #2a2a2a; transition: background-color 0.2s; } .bookmark-list-item:hover { background-color: #333; } .bookmark-list-item .bookmark-link { flex-grow: 1; padding: 12px 15px; font-size: 16px; color: #ddd; text-decoration: none; transition: color 0.2s; display: block; } .bookmark-list-item .bookmark-link:hover { color: #e63946; background-color: transparent; /* Override default hover */ padding-left: 15px; /* Reset or keep simple */ } .delete-button-pages { background: none; border: none; color: #666; cursor: pointer; font-size: 18px; padding: 0 15px; transition: color 0.2s; height: 100%; display: flex; align-items: center; } .delete-button-pages:hover { color: #e63946; } .undo-popup { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 15px; background-color: #333; color: #fff; border-radius: 5px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); display: flex; align-items: center; gap: 10px; z-index: 1000; } .undo-button { background-color: #f1faee; color: #333; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; } .undo-button:hover { background-color: #e63946; color: #1d3557; } @media only screen and (max-width: 600px) { #bookmarksContainer { width: 90%; margin: 10px auto; } .bookmarks-title { font-size: 20px; } .bookmark-link { font-size: 16px; } } `; const styleSheet = document.createElement("style"); styleSheet.type = "text/css"; styleSheet.innerText = styles; document.head.appendChild(styleSheet); // Fetch titles for each bookmark and update dynamically for (const page of bookmarkedPages) { // Append a loading list item first const listItem = $(`<li class="bookmark-list-item"><a href="${page}" class="bookmark-link">Loading...</a><button class="delete-button-pages">✖</button></li>`); bookmarksList.append(listItem); // Using async IIFE to handle async operations in the loop (async () => { try { // Get manga IDs associated with this bookmark const mangaIds = await GM.getValue(`bookmark_manga_ids_${page}`, []); // Determine what to display based on manga IDs let displayText; if (mangaIds.length > 0) { // For single or multiple manga const urlObj = new URL(page); const pathName = urlObj.pathname; const searchParams = urlObj.searchParams.get('q'); let itemCount = mangaIds.length; let itemSuffix = itemCount == 1 ? ' item' : ` items`; let itemPlusSuffix = itemCount == 25 ? `+` : ``; if (pathName.includes('/tag/')) { // For tag pages, extract the tag name const tagName = pathName.split('/tag/')[1].replace('/', ''); displayText = `Tag: ${tagName} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else if (pathName.includes('/artist/')) { // For artist pages, extract the artist name const artistName = pathName.split('/artist/')[1].replace('/', ''); displayText = `Artist: ${artistName} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else if (pathName.includes('/character/')) { // For character pages, extract the character name const characterName = pathName.split('/character/')[1].replace('/', ''); displayText = `Character: ${characterName} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else if (pathName.includes('/parody/')) { // For parody pages, extract the parody name const parodyName = pathName.split('/parody/')[1].replace('/', ''); displayText = `Parody: ${parodyName} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else if (pathName.includes('/group/')) { // For group pages, extract the group name const groupName = pathName.split('/group/')[1].replace('/', ''); displayText = `Group: ${groupName} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else if (searchParams) { // For search results displayText = `Search: ${searchParams} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } else { // Default display for other pages with manga displayText = `${page} (${itemCount}${itemPlusSuffix}${itemSuffix})`; } } else { // If no manga IDs found, fetch title directly displayText = await fetchTitleWithCacheAndRetry(page); } // Update the list item with the fetched title/display text const updatedListItem = $(`<li class="bookmark-list-item"><a href="${page}" class="bookmark-link">${displayText}</a><button class="delete-button-pages">✖</button></li>`); listItem.replaceWith(updatedListItem); // Add delete functionality updatedListItem.find('.delete-button-pages').click(async function() { // Get the latest bookmarked pages to ensure we're working with current data const currentBookmarkedPages = await GM.getValue('bookmarkedPages', []); const updatedBookmarkedPages = currentBookmarkedPages.filter(p => p !== page); await GM.setValue('bookmarkedPages', updatedBookmarkedPages); // Update the local bookmarkedPages variable to keep it in sync bookmarkedPages = updatedBookmarkedPages; // Get the list of manga IDs for this bookmark const bookmarkMangaIds = await GM.getValue(`bookmark_manga_ids_${page}`, []); // Delete the bookmark's manga ID list await GM.deleteValue(`bookmark_manga_ids_${page}`); // For each manga associated with this bookmark const allKeys = await GM.listValues(); const mangaKeys = allKeys.filter(key => key.startsWith('manga_')); for (const key of mangaKeys) { const mangaInfo = await GM.getValue(key); // If this manga is associated with the deleted bookmark if (mangaInfo && mangaInfo.bookmarks && mangaInfo.bookmarks.includes(page)) { // Remove this bookmark from the manga's bookmarks list mangaInfo.bookmarks = mangaInfo.bookmarks.filter(b => b !== page); // If this manga is no longer in any bookmarks, delete it entirely if (mangaInfo.bookmarks.length === 0) { await GM.deleteValue(key); console.log(`Deleted orphaned manga: ${key}`); } else { // Otherwise, update the manga info with the bookmark removed await GM.setValue(key, mangaInfo); console.log(`Updated manga ${key}: removed bookmark reference`); } } } updatedListItem.remove(); console.log(`Deleted bookmark: ${page} and cleaned up related manga data`); const undoPopup = $(` <div class="undo-popup"> <span>Bookmark deleted.</span> <button class="undo-button">Undo</button> </div> `); $('body').append(undoPopup); const timeout = setTimeout(() => { undoPopup.remove(); }, 5000); undoPopup.find('.undo-button').click(async function() { clearTimeout(timeout); const restoredBookmarkedPages = [...updatedBookmarkedPages, page]; await GM.setValue('bookmarkedPages', restoredBookmarkedPages); undoPopup.remove(); $('#bookmarksContainer').remove(); displayBookmarkedPages(); }); }); } catch (error) { console.error(`Error processing bookmark: ${page}`, error); listItem.html(`<a href="${page}" class="bookmark-link">Failed to load</a><button class="delete-button-pages">✖</button>`); } })(); } // Modified version with better cover organization for (const manga of bookmarkedMangas) { const listItem = $(`<li class="bookmark-item"><a href="${manga.url}" class="bookmark-link">Loading...</a><button class="delete-button">✖</button></li>`); mangaBookmarksList.append(listItem); (async () => { // Immediately invoked async function const mangaBookMarkingType = await GM.getValue('mangaBookMarkingType', 'cover'); let title = manga.title; let coverImage = manga.coverImageUrl; if (!title || !coverImage) { try { const info = await fetchMangaInfoWithCacheAndRetry(manga.url); title = info.title; } catch (error) { console.error(`Error fetching info for: ${manga.url}`, error); listItem.html(`<span class="error-text">Failed to fetch data</span>`); return; // Stop processing this item if fetching fails } } // Fetch and store tags let tags = await GM.getValue(`tags_${manga.url}`, null); // Fallback: use readGalleriesCache by gallery id if (!Array.isArray(tags) || tags.length === 0) { const idMatch = (manga.url || '').match(/\/g\/(\d+)\//); if (idMatch && idMatch[1]) { const cachedData = await GM.getValue('readGalleriesCache', {}); const cachedInfo = cachedData[idMatch[1]]; const cachedTags = cachedInfo && Array.isArray(cachedInfo.tags) ? cachedInfo.tags : []; if (cachedTags.length > 0) { tags = cachedTags; } } } if (!Array.isArray(tags) || tags.length === 0) { try { const response = await fetch(manga.url); const html = await response.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); tags = Array.from(doc.querySelectorAll('#tags .tag')).map(tag => { // Remove popularity numbers and format the tag return tag.textContent.replace(/\d+K?$/, '').trim().replace(/\b\w/g, char => char.toUpperCase()); }); console.log(`Fetched tags for ${manga.url}:`, tags); // Log the fetched tags await GM.setValue(`tags_${manga.url}`, tags); // Save tags for future use await addKnownMultiwordTags(tags); } catch (error) { console.error(`Error fetching tags for: ${manga.url}`, error); tags = []; // Default to empty if fetch fails } } else { console.log(`Retrieved cached tags for ${manga.url}:`, tags); // Log cached tags await addKnownMultiwordTags(tags); } let content = ""; if (mangaBookMarkingType === 'cover') { content = ` <div class="cover-container"> <img src="${coverImage}" alt="${title}" class="cover-image"> <div class="title-overlay">${title}</div> </div>`; } else if (mangaBookMarkingType === 'title') { content = `<span class="title-only">${title}</span>`; } else if (mangaBookMarkingType === 'both') { content = ` <div class="cover-with-title"> <img src="${coverImage}" alt="${title}" class="cover-image-small"> <span class="title-text">${title}</span> </div>`; } const updatedListItem = $(`<li class="bookmark-item ${mangaBookMarkingType}-mode"><a href="${manga.url}" class="bookmark-link">${content}</a><button class="delete-button">✖</button></li>`); listItem.replaceWith(updatedListItem); // Add title attribute with tags for hover tooltip const tooltipText = `${title}\n\nTags: ${tags.join(', ')}`; const galleryCaptionTooltipsEnabled = await GM.getValue('galleryCaptionTooltipsEnabled', true); if (galleryCaptionTooltipsEnabled) { updatedListItem.find('.bookmark-link').attr('title', tooltipText); } // Add delete functionality updatedListItem.find('.delete-button').click(async function() { const updatedBookmarkedMangas = bookmarkedMangas.filter(m => m.url !== manga.url); await GM.setValue('bookmarkedMangas', updatedBookmarkedMangas); bookmarkedMangas = updatedBookmarkedMangas; updatedListItem.remove(); const undoPopup = $(` <div class="undo-popup"> <span>Bookmark deleted.</span> <button class="undo-button">Undo</button> </div> `); $('body').append(undoPopup); const timeout = setTimeout(() => { undoPopup.remove(); }, 5000); undoPopup.find('.undo-button').click(async function() { clearTimeout(timeout); const restoredBookmarkedMangas = [...updatedBookmarkedMangas, manga]; await GM.setValue('bookmarkedMangas', restoredBookmarkedMangas); undoPopup.remove(); $('#bookmarksContainer').remove(); displayBookmarkedPages(); }); }); })(); // Execute the async function immediately } // Add this CSS to your styles const additionalStyles = ` #mangaBookmarksList { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 15px; list-style-type: none; padding: 10px; max-height: 70vh; overflow-y: auto; background: #222; border: 1px solid #444; border-radius: 4px; scrollbar-width: thin; scrollbar-color: #e63946 #2c2c2c; } #mangaBookmarksList::-webkit-scrollbar { width: 8px; } #mangaBookmarksList::-webkit-scrollbar-track { background: #2c2c2c; } #mangaBookmarksList::-webkit-scrollbar-thumb { background-color: #e63946; border-radius: 4px; } .bookmark-item { position: relative; } .bookmark-item.cover-mode { text-align: center; } .cover-container { position: relative; width: 100%; height: 0; padding-bottom: 140%; /* Aspect ratio for typical manga covers */ overflow: hidden; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } .cover-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; } .cover-container:hover .cover-image { transform: scale(1.05); } .title-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: white; padding: 5px; font-size: 12px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .delete-button { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.5); color: #ffffff; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; opacity: 0; transition: opacity 0.2s ease; text-align: center; display: flex; align-items: center; justify-content: center; } @media only screen and (max-width: 768px) { .delete-button { font-size: 10px; } } .bookmark-item:hover .delete-button { opacity: 1; } /* Hide default browser tooltip */ .bookmark-link[title] { position: relative; } /* Gallery caption hover tooltip styling */ .gallery .caption[title].tooltip-enabled:hover::after { content: attr(title); position: fixed; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px 12px; border-radius: 6px; font-size: 12px; line-height: 1.4; white-space: pre-line; max-width: 300px; word-wrap: break-word; z-index: 999999; pointer-events: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.2); backdrop-filter: blur(4px); top: 20px; left: 20px; } /* General tooltip styling for any element with title attribute */ [title]:hover::after { content: attr(title); position: fixed !important; background: rgba(0, 0, 0, 0.9) !important; color: white !important; padding: 8px 12px !important; border-radius: 6px !important; font-size: 12px !important; line-height: 1.4 !important; white-space: pre-line !important; max-width: 300px !important; word-wrap: break-word !important; z-index: 999999 !important; pointer-events: none !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; backdrop-filter: blur(4px) !important; top: 20px !important; left: 20px !important; } .title-only { display: block; padding: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cover-with-title { display: flex; align-items: center; } .cover-image-small { width: 50px; height: 70px; object-fit: cover; margin-right: 10px; border-radius: 3px; } /* Default styles for desktop */ .bookmarks-grid { list-style: none; padding: 0; max-height: 100%; overflow-y: hidden; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); /* adjust the min and max widths as needed */ gap: 10px; /* adjust the gap between grid items as needed */ } /* Styles for mobile devices */ @media only screen and (max-width: 768px) { .bookmarks-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* adjust the grid item width for mobile */ gap: 5px; /* adjust the gap between grid items for mobile */ } } .title-text { flex: 1; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } /* Modified search to work with new layout */ .bookmark-item.hidden { display: none; } .random-button { background-color: #e63946; color: #ffffff; border: none; padding: 5px 10px; font-size: 14px; cursor: pointer; border-radius: 5px; transition: background-color 0.2s ease; } .random-button:hover { background-color:rgb(255, 255, 255); color: #e63946; } .random-button:active { transform: translateY(2px); } .random-button i { margin-right: 10px; } `; // Add the CSS to the page $('<style>').text(additionalStyles).appendTo('head'); // Modified search functionality to work with the new layout pageTitleInput.on('input', filterPages); pageTagInput.on('input', filterPages); mangaTitleInput.on('input', filterManga); mangaTagInput.on('input', filterManga); function filterManga() { const searchQuery = mangaTitleInput.val().toLowerCase(); const tagQueries = mangaTagInput.val().toLowerCase().trim().split(/,\s*|\s+/); mangaBookmarksList.children('li').each(async function () { const $li = $(this); const mangaUrl = $li.find('.bookmark-link').attr('href'); const tags = await GM.getValue(`tags_${mangaUrl}`, []); const cleanedTags = tags.map(tag => tag.replace(/\d+K?$/, '').trim().toLowerCase() ); const textContent = $li.find('.bookmark-link').text().toLowerCase(); const imageSrc = $li.find('.bookmark-link img').attr('src') || ''; const searchMatch = textContent.includes(searchQuery) || imageSrc.toLowerCase().includes(searchQuery); const tagMatch = (tagQueries.length === 1 && tagQueries[0] === "") ? true : tagQueries.every(query => { const queryWords = query.split(/\s+/); return cleanedTags.some(tag => queryWords.every(word => tag.includes(word)) ); }); $li.toggleClass('hidden', !(searchMatch && tagMatch)); }); } function filterPages() { const searchQuery = pageTitleInput.val().toLowerCase(); const tagQueries = pageTagInput.val().toLowerCase().trim().split(/,\s*|\s+/); $('.bookmarks-list li').each(async function () { const $li = $(this); const bookmarkUrl = $li.find('.bookmark-link').attr('href'); let matchFound = false; // Get all manga IDs associated with this bookmark const mangaIds = await GM.getValue(`bookmark_manga_ids_${bookmarkUrl}`, []); const searchContent = $li.find('.bookmark-link').text().toLowerCase(); const searchMatch = searchContent.includes(searchQuery); const isTagQueryEmpty = (tagQueries.length === 1 && tagQueries[0] === ""); if (!mangaIds || mangaIds.length === 0) { // If no manga IDs, match only on title and if no tag query if (searchMatch && isTagQueryEmpty) { $li.toggleClass('hidden', false); } else { $li.toggleClass('hidden', true); } return; } // Check each manga in this bookmark for matching tags for (const mangaId of mangaIds) { const mangaData = await GM.getValue(`manga_${mangaId}`, null); if (!mangaData || !mangaData.tags) continue; const cleanedTags = mangaData.tags.map(tag => tag.replace(/\d+K?$/, '').trim().toLowerCase() ); const tagMatch = isTagQueryEmpty ? true : tagQueries.every(query => { const queryWords = query.split(/\s+/); return cleanedTags.some(tag => queryWords.every(word => tag.includes(word)) ); }); if (searchMatch && tagMatch) { matchFound = true; break; } } $li.toggleClass('hidden', !matchFound); }); } } else { console.error('Bookmarked pages or mangas is not an array'); } } // Function to fetch manga info (title and cover image) with cache and retry async function fetchMangaInfoWithCacheAndRetry(manga) { const cacheKey = `manga-info-${manga}`; const cachedInfo = await GM.getValue(cacheKey); if (cachedInfo) { return cachedInfo; } try { const response = await fetch(manga); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const title = doc.querySelector('h1.title').textContent; const coverImage = doc.querySelector('#cover img').src; const info = { title, coverImage }; await GM.setValue(cacheKey, info); return info; } catch (error) { console.error(`Error fetching manga info for: ${manga}`, error); throw error; } } // Call the function to display bookmarked pages with active loading displayBookmarkedPages(); } })(); // ------------------------ *Bookmarks** ------------------ //------------------------ **Nhentai English Filter** ---------------------- var pathname = window.location.pathname; var searchQuery = window.location.search.split('=')[1] || ''; var namespaceType = pathname.split('/')[1]; var namespaceQuery = pathname.split('/')[2]; var namespaceSearchLink = '<div class="sort-type"><a href="https://nhentai.net/search/?q=' + namespaceType + '%3A%22' + namespaceQuery + '%22+language%3A%22english%22">English Only</a></div>'; var siteSearchLink = '<div class="sort-type"><a href="https://nhentai.net/search/?q=' + searchQuery + '+language%3A%22english%22">English Only</a></div>'; var favSearchBtn = '<a class="btn btn-primary" href="https://nhentai.net/favorites/?q=language%3A%22english%22+' + searchQuery + '"><i class="fa fa-flag"></i> ENG</a>'; var favPageBtn = '<a class="btn btn-primary" href="https://nhentai.net/favorites/?q=language%3A%22english%22+"><i class="fa fa-flag"></i> ENG</a>'; (async function() { const englishFilterEnabled = await GM.getValue('englishFilterEnabled', true); if (englishFilterEnabled) { // Check if the search query contains 'English' or 'english' if (!/(English|language%3A%22english%22)/i.test(searchQuery)) { if (pathname.startsWith('/parody/')) { // parody pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/favorites/')) { // favorites pages if (window.location.search.length) { document.getElementById('favorites-random-button').insertAdjacentHTML('afterend', favSearchBtn); } else { document.getElementById('favorites-random-button').insertAdjacentHTML('afterend', favPageBtn); } } else if (pathname.startsWith('/artist/')) { // artist pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/tag/')) { // tag pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/group/')) { // group pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/category/')) { // category pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/character/')) { // character pages document.getElementsByClassName('sort')[0].innerHTML += namespaceSearchLink; } else if (pathname.startsWith('/search/')) { // search pages document.getElementsByClassName('sort')[0].innerHTML += siteSearchLink; } } } })(); //------------------------ **Nhentai English Filter** ---------------------- //------------------------ **Nhentai Auto Login** -------------------------- (async function() { const autoLoginEnabled = await GM.getValue('autoLoginEnabled', true); const email = await GM.getValue('email'); const password = await GM.getValue('password'); // Login page if (autoLoginEnabled && window.location.href.includes('/login/?next=/')) { if (!email || !password) { GM.setValue('email', prompt('Please enter your email:')); GM.setValue('password', prompt('Please enter your password:')); } document.getElementById('id_username_or_email').value = email; document.getElementById('id_password').value = password; const errorMessage = document.querySelector('#errors'); if (!errorMessage || !errorMessage.textContent.includes('You need to solve the CAPTCHA.')) { document.querySelector('button[type="submit"]').click(); } else { console.log('CAPTCHA detected. Cannot auto-login.'); } } })(); //------------------------ **Nhentai Auto Login** -------------------------- //----------------------------**Settings**----------------------------- // Function to add the settings button to the menu function addSettingsButton() { // Create the settings button const settingsButtonHtml = ` <li> <a href="/settings/"> <i class="fa fa-cog"></i> Settings </a> </li> `; const settingsButton = $(settingsButtonHtml); // Append the settings button to the dropdown menu and the left menu const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(settingsButton); const menu = $('ul.menu.left'); menu.append(settingsButton); } // Call the function to add the settings button addSettingsButton(); // Handle settings page if (window.location.href.includes('/settings')) { // Remove 404 Not Found elements const notFoundHeading = document.querySelector('h1'); if (notFoundHeading && notFoundHeading.textContent === '404 – Not Found') { notFoundHeading.remove(); } const notFoundMessage = document.querySelector('p'); if (notFoundMessage && notFoundMessage.textContent === "Looks like what you're looking for isn't here.") { notFoundMessage.remove(); } // Add settings form and random hentai preferences const settingsHtml = ` <style> /* Modern Settings UI */ :root { --nh-bg: #1f1f1f; --nh-bg-dark: #111; --nh-bg-light: #2a2a2a; --nh-accent: #ed2553; --nh-text: #f1f1f1; --nh-text-muted: #aaa; --nh-border: #333; } #content { background: transparent !important; padding: 0 !important; border: none !important; max-width: 1200px; margin: 20px auto; } .settings-wrapper { display: flex; background: var(--nh-bg); border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); border: 1px solid var(--nh-border); min-height: 600px; overflow: hidden; } /* Sidebar */ .settings-sidebar { width: 250px; background: var(--nh-bg-light); border-right: 1px solid var(--nh-border); display: flex; flex-direction: column; flex-shrink: 0; } .settings-header { padding: 20px; background: rgba(0,0,0,0.2); border-bottom: 1px solid var(--nh-border); } .settings-header h2 { margin: 0; font-size: 18px; color: var(--nh-accent); display: flex; align-items: center; gap: 10px; } .settings-nav { list-style: none; padding: 0; margin: 0; overflow-y: auto; } .nav-item { padding: 15px 20px; cursor: pointer; color: var(--nh-text-muted); transition: all 0.2s; border-left: 3px solid transparent; display: flex; align-items: center; gap: 12px; font-size: 14px; } .nav-item:hover { background: rgba(255,255,255,0.05); color: var(--nh-text); } .nav-item.active { background: rgba(237, 37, 83, 0.1); border-left-color: var(--nh-accent); color: var(--nh-text); font-weight: 600; } .nav-item i { width: 20px; text-align: center; } /* Content */ .settings-main { flex: 1; padding: 30px; overflow-y: auto; max-height: 85vh; } .tab-content { display: none; animation: fadeIn 0.3s ease; } .tab-content.active { display: block; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .section-title { font-size: 24px; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--nh-border); color: var(--nh-text); } /* Cards & Groups */ .setting-card { background: rgba(255,255,255,0.02); border: 1px solid var(--nh-border); border-radius: 6px; padding: 20px; margin-bottom: 20px; } .setting-card h3 { margin-top: 0; font-size: 16px; color: var(--nh-accent); margin-bottom: 15px; } .setting-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.05); } .setting-row:last-child { border-bottom: none; } .setting-info { flex: 1; padding-right: 20px; } .setting-label { font-weight: 500; display: block; margin-bottom: 4px; } .setting-desc { font-size: 12px; color: var(--nh-text-muted); } /* Form Elements */ input[type="text"], input[type="password"], input[type="number"], select, textarea { background: var(--nh-bg-dark); border: 1px solid var(--nh-border); color: var(--nh-text); padding: 8px 12px; border-radius: 4px; width: 100%; box-sizing: border-box; } input[type="text"]:focus, input[type="password"]:focus { border-color: var(--nh-accent); outline: none; } /* Toggle Switch */ .toggle-switch { position: relative; display: inline-block; width: 46px; height: 24px; } .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: #444; transition: .3s; border-radius: 24px; } .toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; } input:checked + .toggle-slider { background-color: var(--nh-accent); } input:checked + .toggle-slider:before { transform: translateX(22px); } /* Buttons */ .btn { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-weight: 600; transition: all 0.2s; } .btn-primary { background: var(--nh-accent); color: white; } .btn-primary:hover { background: #c91841; } .btn-secondary { background: #444; color: white; } .btn-secondary:hover { background: #555; } .save-bar { position: sticky; bottom: 0; background: var(--nh-bg-light); padding: 15px 30px; border-top: 1px solid var(--nh-border); display: flex; justify-content: flex-end; z-index: 100; } /* Specific overrides from original CSS */ .nhp-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 9999; } .nhp-modal-content { background: var(--nh-bg); border: 1px solid var(--nh-border); border-radius: 8px; padding: 20px; width: 500px; max-width: 90%; max-height: 80vh; overflow-y: auto; } .nhp-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } /* Responsive */ @media (max-width: 768px) { .settings-wrapper { flex-direction: column; } .settings-sidebar { width: 100%; height: auto; border-right: none; border-bottom: 1px solid var(--nh-border); } .settings-nav { display: flex; overflow-x: auto; padding: 10px; } .nav-item { white-space: nowrap; padding: 10px; border-left: none; border-bottom: 2px solid transparent; } .nav-item.active { border-left: none; border-bottom-color: var(--nh-accent); } } /* Classic Layout Mode */ .settings-wrapper.classic-mode .settings-sidebar { display: none; } .settings-wrapper.classic-mode { display: block; } .settings-wrapper.classic-mode .tab-content { display: block !important; margin-bottom: 30px; animation: none; } .settings-wrapper.classic-mode .settings-main { max-height: none; overflow: visible; } .settings-wrapper.classic-mode .section-title { position: sticky; top: 0; background: var(--nh-bg); padding-top: 10px; z-index: 10; border-bottom: 2px solid var(--nh-accent); } /* Keeping original utility classes */ .tooltip { display: inline-block; margin-left: 5px; color: var(--nh-text-muted); cursor: help; } .tooltip:hover { color: var(--nh-text); } /* Fix for specific sections */ #advanced-settings-content, #online-sync-settings-content { padding-top: 10px; } .sortable-list { list-style: none; padding: 0; } .tab-item { background: var(--nh-bg-light); padding: 10px; margin-bottom: 5px; border-radius: 4px; display: flex; align-items: center; gap: 10px; cursor: grab; } /* Edit Value Modal */ #edit-value-modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px); } #edit-value-content { background-color: var(--nh-bg); margin: 10% auto; padding: 25px; border: 1px solid var(--nh-border); width: 80%; max-width: 600px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: fadeIn 0.3s; } #edit-value-textarea { width: 100%; height: 300px; margin-bottom: 20px; background-color: var(--nh-bg-dark); color: var(--nh-text); border: 1px solid var(--nh-border); padding: 10px; font-family: monospace; resize: vertical; } .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; } </style> <div id="content"> <div class="settings-wrapper"> <!-- Sidebar --> <div class="settings-sidebar"> <div class="settings-header"> <h2><i class="fa fa-cog"></i> Settings</h2> </div> <ul class="settings-nav"> <li class="nav-item active" data-tab="general"><i class="fa fa-sliders-h"></i> General</li> <li class="nav-item" data-tab="reading"><i class="fa fa-book-open"></i> Reading</li> <li class="nav-item" data-tab="tags"><i class="fa fa-tags"></i> Tags</li> <li class="nav-item" data-tab="bookmarks"><i class="fa fa-bookmark"></i> Bookmarks</li> <li class="nav-item" data-tab="sync"><i class="fa fa-cloud"></i> Sync & Data</li> <li class="nav-item" data-tab="social"><i class="fa fa-share-alt"></i> Inbox & Social</li> <li class="nav-item" data-tab="pages"><i class="fa fa-file-alt"></i> Custom Pages</li> <li class="nav-item" data-tab="layout"><i class="fa fa-layer-group"></i> Layout</li> </ul> </div> <!-- Main Content --> <form id="settingsForm" class="settings-main"> <!-- Tab: General --> <div id="tab-general" class="tab-content active"> <h2 class="section-title">General Settings</h2> <div class="setting-card"> <h3>Account</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Auto Login</span> <span class="setting-desc">Automatically log in with saved credentials</span> </div> <label class="toggle-switch"> <input type="checkbox" id="autoLoginEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="autoLoginCredentials" style="margin-top: 15px; display: none;"> <div style="margin-bottom: 10px;"> <label style="display:block; margin-bottom:5px;">Email</label> <input type="text" id="email" placeholder="Email"> </div> <div> <label style="display:block; margin-bottom:5px;">Password</label> <input type="password" id="password" placeholder="Password"> </div> </div> </div> <div class="setting-card"> <h3>Filtering</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">English Filter Button</span> <span class="setting-desc">Add button to filter for English translations only</span> </div> <label class="toggle-switch"> <input type="checkbox" id="englishFilterEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Month Filter</span> <span class="setting-desc">Filter manga by publication month</span> </div> <label class="toggle-switch"> <input type="checkbox" id="monthFilterEnabled"> <span class="toggle-slider"></span> </label> </div> </div> <div class="setting-card"> <h3>Interface</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Show Tooltips</span> <span class="setting-desc">Enable help tooltips on hover (in settings)</span> </div> <label class="toggle-switch"> <input type="checkbox" id="tooltipsEnabled"> <span class="toggle-slider"></span> </label> </div> </div> </div> <!-- Tab: Reading --> <div id="tab-reading" class="tab-content"> <h2 class="section-title">Reading Experience</h2> <div class="setting-card"> <h3>Interface</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Show Page Numbers</span> <span class="setting-desc">Display page count on manga thumbnails</span> </div> <label class="toggle-switch"> <input type="checkbox" id="showPageNumbersEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Gallery Tooltips</span> <span class="setting-desc">Show details on hover over covers</span> </div> <label class="toggle-switch"> <input type="checkbox" id="galleryCaptionTooltipsEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Open in New Tab</span> <span class="setting-desc">Open galleries in a new tab</span> </div> <label class="toggle-switch"> <input type="checkbox" id="openInNewTabEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="open-in-New-Tab-options" style="margin-top: 10px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 4px; display: none;"> <label style="display: block; margin-bottom: 5px;"> <input type="radio" id="open-in-new-tab-background" name="open-in-new-tab" value="background"> Background </label> <label style="display: block;"> <input type="radio" id="open-in-new-tab-foreground" name="open-in-new-tab" value="foreground"> Foreground </label> </div> </div> <div class="setting-card"> <h3>Discovery</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Find Similar Button</span> <span class="setting-desc">Find manga similar to the current one</span> </div> <label class="toggle-switch"> <input type="checkbox" id="findSimilarEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="find-similar-options" style="margin-top: 10px; display: none;"> <label style="margin-right: 15px;"><input type="radio" id="open-immediately" name="find-similar-type" value="immediately"> Open Immediately</label> <label><input type="radio" id="input-tags" name="find-similar-type" value="input-tags"> Input Tags</label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Find Alt Manga</span> <span class="setting-desc">Find alternative sources</span> </div> <label class="toggle-switch"> <input type="checkbox" id="findAltmangaEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Find Alt Manga (Thumbnail)</span> <span class="setting-desc">Show alternatives as thumbnails</span> </div> <label class="toggle-switch"> <input type="checkbox" id="findAltMangaThumbnailEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="find-Alt-Manga-Thumbnail-options" style="margin-top: 10px; display: none;"> <label><input type="checkbox" id="mangagroupingenabled" name="manga-grouping-type" value="grouping"> Group alt versions on page</label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Replace with Bookmarks</span> <span class="setting-desc">Show bookmarks in Related Manga section</span> </div> <label class="toggle-switch"> <input type="checkbox" id="replaceRelatedWithBookmarks"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Related Flip Button</span> <span class="setting-desc">Flip between Related and Bookmarks</span> </div> <label class="toggle-switch"> <input type="checkbox" id="enableRelatedFlipButton"> <span class="toggle-slider"></span> </label> </div> </div> <div class="setting-card"> <h3>Random Hentai Preferences</h3> <div style="margin-bottom: 10px;"> <label style="display:block; margin-bottom:5px;">Preferred Language</label> <input type="text" id="pref-language" placeholder="e.g. english, japanese"> </div> <div style="margin-bottom: 10px;"> <label style="display:block; margin-bottom:5px;">Preferred Tags</label> <input type="text" id="pref-tags" placeholder="e.g. big breasts, stockings"> </div> <div style="margin-bottom: 10px;"> <label style="display:block; margin-bottom:5px;">Blacklisted Tags</label> <input type="text" id="blacklisted-tags" placeholder="e.g. guro, scat"> </div> <div style="display:flex; gap:10px; margin-bottom:10px;"> <div style="flex:1;"> <label style="display:block; margin-bottom:5px;">Min Pages</label> <input type="number" id="pref-pages-min"> </div> <div style="flex:1;"> <label style="display:block; margin-bottom:5px;">Max Pages</label> <input type="number" id="pref-pages-max"> </div> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Match All Tags</span> <span class="setting-desc">If unchecked, matches any tag</span> </div> <label class="toggle-switch"> <input type="checkbox" id="matchAllTags"> <span class="toggle-slider"></span> </label> </div> </div> <div class="setting-card"> <h3>Fade & Read</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Mark as Read System</span> <span class="setting-desc">Visually mark read galleries</span> </div> <label class="toggle-switch"> <input type="checkbox" id="markAsReadEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Auto-mark as Read</span> <span class="setting-desc">Mark as read when reaching last page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="autoMarkReadEnabled"> <span class="toggle-slider"></span> </label> </div> <div style="margin-top: 15px;"> <label style="display:block; margin-bottom:5px;">Non-English Opacity: <span id="nonEnglishOpacityValue">0.2</span></label> <input type="range" id="nonEnglishOpacity" min="0.1" max="1.0" step="0.1" value="0.2"> </div> <div style="margin-top: 15px;"> <label style="display:block; margin-bottom:5px;">Read Galleries Opacity: <span id="readGalleriesOpacityValue">0.6</span></label> <input type="range" id="readGalleriesOpacity" min="0.1" max="1.0" step="0.1" value="0.6"> </div> </div> </div> <!-- Tab: Tags --> <div id="tab-tags" class="tab-content"> <h2 class="section-title">Tag Management</h2> <div class="setting-card"> <h3>Warnings & Blacklist</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Tag Warning System</span> <span class="setting-desc">Show badges for warning/blacklisted tags</span> </div> <label class="toggle-switch"> <input type="checkbox" id="tagWarningEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Hide/Blacklist System</span> <span class="setting-desc">Hide manga with specific tags</span> </div> <label class="toggle-switch"> <input type="checkbox" id="hideBlacklistEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Auto-hide Blacklisted</span> <span class="setting-desc">Automatically hide galleries with blacklisted tags</span> </div> <label class="toggle-switch"> <input type="checkbox" id="autoHideBlacklistedTagsEnabled"> <span class="toggle-slider"></span> </label> </div> <div style="margin-top: 15px; display: flex; gap: 10px;"> <button type="button" id="toggleTagLists" class="btn btn-primary">Manage Tags</button> <button type="button" id="manageHiddenManga" class="btn btn-secondary">Hidden Manga <span id="hiddenMangaCount"></span></button> </div> </div> <div class="setting-card"> <h3>Smart Tags</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Smart Tags</span> <span class="setting-desc">Auto-wrap inputs in search syntax</span> </div> <label class="toggle-switch"> <input type="checkbox" id="smartTagEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Must Add Tags</span> <span class="setting-desc">Always include specific tags in search</span> </div> <label class="toggle-switch"> <input type="checkbox" id="mustAddTagsEnabled"> <span class="toggle-slider"></span> </label> </div> <div style="margin-top: 10px;"> <input type="text" id="must-add-tags" placeholder="e.g. english, -scat"> </div> </div> <div class="setting-card"> <h3>Language Visibility</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Non-English Manga</span> </div> <select id="showNonEnglishSelect" style="width: auto;"> <option value="show">Show</option> <option value="hide">Hide</option> <option value="fade">Fade</option> </select> </div> </div> <!-- Modals Container (Hidden) --> <div id="tagManagementModal" class="nhp-modal"> <div class="nhp-modal-content"> <div class="nhp-modal-header"> <span class="title">Manage Tags</span> <button type="button" id="closeTagManagementModal" class="close-btn">×</button> </div> <div class="nhp-modal-list"> <div class="tag-list-section"> <h4>Blacklist Tags (Red)</h4> <textarea id="blacklistTags" placeholder="e.g. scat, guro" rows="3"></textarea> </div> <div class="tag-list-section"> <h4>Warning Tags (Orange)</h4> <textarea id="warningTags" placeholder="e.g. ntr, cheating" rows="3"></textarea> </div> <div class="tag-list-section"> <h4>Favorite Tags (Blue)</h4> <textarea id="favoriteTags" rows="3" readonly></textarea> <button type="button" id="clearFavoriteTags" class="btn-secondary" style="margin-top:5px">Clear Favorites</button> </div> <div class="tag-list-section"> <h4>Smart Tag Overrides</h4> <div id="smart-tag-overrides"> <div id="overrides-list"></div> <div style="margin-top:10px; display:flex; gap:8px;"> <input type="text" id="override-name-input" placeholder="Tag name"> <select id="override-type-select"> <option value="artist">artist</option> <option value="group">group</option> <option value="parody">parody</option> <option value="character">character</option> <option value="tag">tag</option> </select> <button type="button" id="add-override-btn" class="btn-secondary">Add</button> </div> </div> </div> </div> </div> </div> <div id="hiddenMangaModal" class="nhp-modal"> <div class="nhp-modal-content"> <div class="nhp-modal-header"> <span class="title">Hidden Manga</span> <button type="button" id="closeHiddenMangaModal" class="close-btn">×</button> </div> <div id="hiddenMangaList" class="nhp-modal-list"></div> </div> </div> </div> <!-- Tab: Bookmarks --> <div id="tab-bookmarks" class="tab-content"> <h2 class="section-title">Bookmarks</h2> <div class="setting-card"> <h3>Functionality</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Bookmarks Button</span> <span class="setting-desc">Enable bookmarking feature</span> </div> <label class="toggle-switch"> <input type="checkbox" id="bookmarksEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Bookmark Link</span> <span class="setting-desc">Add link to bookmark in manga title</span> </div> <label class="toggle-switch"> <input type="checkbox" id="bookmarkLinkEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Manga Bookmarking Button</span> </div> <label class="toggle-switch"> <input type="checkbox" id="mangaBookMarkingButtonEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="manga-bookmarking-options" style="margin-top: 10px; display: none;"> <label><input type="radio" id="manga-bookmarking-cover" name="manga-bookmarking-type" value="cover"> Cover</label> <label><input type="radio" id="manga-bookmarking-title" name="manga-bookmarking-type" value="title"> Title</label> <label><input type="radio" id="manga-bookmarking-both" name="manga-bookmarking-type" value="both"> Both</label> </div> </div> <div class="setting-card"> <h3>Data Management</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Import/Export</span> </div> <div class="bookmark-actions"> <button type="button" id="exportBookmarks" class="btn btn-secondary">Export</button> <button type="button" id="importBookmarks" class="btn btn-secondary">Import</button> <input type="file" id="importBookmarksFile" accept=".json" style="display:none"> </div> </div> <div style="margin-top: 15px;"> <label style="display:block; margin-bottom:5px;">Max Manga per Bookmark: <span id="max-manga-per-bookmark-on-mobile-value">5</span></label> <input type="range" id="max-manga-per-bookmark-slider" min="1" max="25" value="5"> </div> </div> </div> <!-- Tab: Sync & Data --> <div id="tab-sync" class="tab-content"> <h2 class="section-title">Sync & Data</h2> <!-- Preserving original Sync HTML structure --> <div id="online-sync-settings"> <div class="setting-card"> <h3 id="online-sync-header" style="cursor:pointer; display:flex; justify-content:space-between; align-items:center;"> Online Data Sync <i class="fa fa-chevron-down"></i> </h3> </div> <div id="online-sync-settings-content"> <div class="setting-card"> <h3>User Identification</h3> <div class="sync-section" style="border:none; padding:0; background:transparent;"> <label> Your UUID: <div class="uuid-controls" style="display:inline-block;"> <input type="text" id="userUUID" readonly style="width: 100px !important;"> <button type="button" id="edit-uuid" class="btn btn-secondary" style="padding:2px 8px; font-size:12px;">Edit</button> <button type="button" id="regenerate-uuid" class="btn btn-secondary" style="padding:2px 8px; font-size:12px;">Regen</button> <button type="button" id="browse-users" class="btn btn-secondary" style="padding:2px 8px; font-size:12px;">Browse</button> </div> </label> <div id="uuid-edit-warning" style="display: none; color: #ff6b6b; font-size: 12px; margin-top: 5px;"> ⚠️ Warning: Changing your UUID will affect data access. </div> <div id="available-users" style="display: none; margin-top: 10px; padding: 10px; background: #333; border-radius: 3px;"> <div id="users-list"></div> <button type="button" id="close-users-list" class="btn btn-secondary" style="margin-top:5px">Close</button> </div> </div> </div> <div class="setting-card"> <h3>Public Sync</h3> <div class="setting-row"> <span class="setting-label">Enable Public Sync</span> <label class="toggle-switch"> <input type="checkbox" id="publicSyncEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="public-sync-options" style="display: none; margin-top:10px;"> <div class="sync-controls"> <button type="button" id="public-sync-upload" class="btn btn-secondary">Upload</button> <button type="button" id="public-sync-download" class="btn btn-secondary">Download</button> <span id="public-sync-status" class="sync-status"></span> </div> <div class="sync-info"><small>Last sync: <span id="public-last-sync">Never</span></small></div> </div> </div> <div class="setting-card"> <h3>Private Sync</h3> <div class="setting-row"> <span class="setting-label">Enable Private Sync</span> <label class="toggle-switch"> <input type="checkbox" id="privateSyncEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="private-sync-options" style="display: none; margin-top:10px;"> <input type="text" id="privateStorageUrl" placeholder="Storage URL" style="margin-bottom:10px;"> <input type="password" id="privateApiKey" placeholder="API Key" style="margin-bottom:10px;"> <div class="sync-controls"> <button type="button" id="private-sync-upload" class="btn btn-secondary">Upload</button> <button type="button" id="private-sync-download" class="btn btn-secondary">Download</button> <span id="private-sync-status" class="sync-status"></span> </div> <div class="sync-info"><small>Last sync: <span id="private-last-sync">Never</span></small></div> </div> </div> <div class="setting-card"> <h3>Auto Sync</h3> <div class="setting-row"> <span class="setting-label">Enable Auto Sync</span> <label class="toggle-switch"> <input type="checkbox" id="autoSyncEnabled"> <span class="toggle-slider"></span> </label> </div> <div style="margin-top:10px;"> <label>Interval (min): <input type="number" id="syncInterval" min="5" style="width:80px;"></label> <div id="auto-sync-status" style="font-size:12px; margin-top:5px; color:#aaa;"></div> <button id="trigger-auto-sync" class="btn btn-secondary" style="margin-top:5px;">Sync Now</button> </div> </div> </div> </div> <div id="advanced-settings"> <div class="setting-card"> <h3 id="storage-explorer-header" style="cursor:pointer; display:flex; justify-content:space-between; align-items:center;"> Storage Explorer <i class="fa fa-chevron-down"></i> </h3> <div id="advanced-settings-content" style="display:none; margin-top:15px; border-top:1px solid rgba(255,255,255,0.1); padding-top:15px;"> <button type="button" id="refresh-storage" class="btn btn-secondary">Refresh Data</button> <div id="storage-keys-list" style="margin-top:15px;"></div> </div> </div> </div> <!-- Advanced Storage Modal --> <div id="edit-value-modal"> <div id="edit-value-content"> <h3>Edit Value</h3> <p id="editing-key-name">Key: </p> <textarea id="edit-value-textarea"></textarea> <div class="modal-buttons"> <button type="button" id="cancel-edit" class="btn btn-secondary">Cancel</button> <button type="button" id="save-edit" class="btn btn-primary">Save</button> </div> </div> </div> </div> <!-- Tab: Inbox & Social --> <div id="tab-social" class="tab-content"> <h2 class="section-title">Inbox & Social</h2> <div class="setting-card"> <h3>Inbox</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Enable Inbox Polling</span> </div> <label class="toggle-switch"> <input type="checkbox" id="receiveSharesEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Show Popups</span> </div> <label class="toggle-switch"> <input type="checkbox" id="receivePopupsEnabled"> <span class="toggle-slider"></span> </label> </div> <div style="margin-top:10px;"> <label>Poll Interval (min): <input type="number" id="inboxPollIntervalMin" min="1" style="width:80px;"></label> <div style="margin-top:10px;"> <button type="button" id="checkInboxNow" class="btn btn-secondary">Check Now</button> <button type="button" id="clearInbox" class="btn btn-secondary">Clear</button> </div> <div id="inbox-list" style="margin-top:15px;"></div> </div> </div> <div class="setting-card"> <h3>Social Features</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Share Button</span> </div> <label class="toggle-switch"> <input type="checkbox" id="shareButtonEnabled"> <span class="toggle-slider"></span> </label> </div> <h4 style="margin-top:20px; margin-bottom:10px; color:var(--nh-text-muted);">Remove Buttons</h4> <div class="setting-row"> <span class="setting-label">Twitter</span> <label class="toggle-switch"><input type="checkbox" id="twitterButtonEnabled"><span class="toggle-slider"></span></label> </div> <div class="setting-row"> <span class="setting-label">Profile</span> <label class="toggle-switch"><input type="checkbox" id="profileButtonEnabled"><span class="toggle-slider"></span></label> </div> <div class="setting-row"> <span class="setting-label">Info</span> <label class="toggle-switch"><input type="checkbox" id="infoButtonEnabled"><span class="toggle-slider"></span></label> </div> <div class="setting-row"> <span class="setting-label">Logout</span> <label class="toggle-switch"><input type="checkbox" id="logoutButtonEnabled"><span class="toggle-slider"></span></label> </div> </div> </div> <!-- Tab: Custom Pages --> <div id="tab-pages" class="tab-content"> <h2 class="section-title">Custom Pages</h2> <div class="setting-card"> <h3>Feature Pages</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Offline Favorites Page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="offlineFavoritesPageEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Offline Favoriting</span> </div> <label class="toggle-switch"> <input type="checkbox" id="offlineFavoritingEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Read Manga Page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="readMangaPageEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="read-manga-page-options" style="display:none; margin-top:10px;"> <label>Max Display: <input type="range" id="max-read-manga-display-slider" min="10" max="500" step="10"></label> <span id="max-read-manga-display-value"></span> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Quick Nut Page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="quickNutPageEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="quick-nut-page-options" style="display:none; margin-top:10px;"> <label style="display:block;"><input type="checkbox" id="quickNutEnglishOnlyEnabled"> Only English</label> <label style="display:block;"><input type="checkbox" id="quickNutSkipReadEnabled"> Skip Read</label> <select id="quickNutRefreshMode" style="margin-top:5px;"> <option value="daily">Every 24 hours</option> <option value="per_visit">Every visit</option> <option value="custom">Custom</option> </select> <div id="quickNutCustomMinutesContainer" style="display:none; margin-top:5px;"> <input type="number" id="quickNutCustomMinutes" placeholder="Minutes"> </div> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">NFM Page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="nfmPageEnabled"> <span class="toggle-slider"></span> </label> </div> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Bookmarks Page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="bookmarksPageEnabled"> <span class="toggle-slider"></span> </label> </div> <div id="bookmark-page-options" style="display:none; margin-top:10px;"> <select id="bookmark-arrangement-type"> <option value="default">Default</option> <option value="alphabetical">A-Z</option> <option value="reverse-alphabetical">Z-A</option> <option value="most-items">Most Items</option> <option value="least-items">Least Items</option> </select> <div style="margin-top:10px;"> <label><input type="checkbox" id="enableRandomButton"> Enable Random Button</label> </div> <div id="random-options" style="display:none; margin-top:5px; margin-left:15px;"> <label><input type="radio" id="random-open-in-new-tab" name="random-open-type" value="new-tab"> New Tab</label> <label><input type="radio" id="random-open-in-current-tab" name="random-open-type" value="current-tab"> Current Tab</label> </div> </div> </div> </div> <!-- Tab: Layout --> <div id="tab-layout" class="tab-content"> <h2 class="section-title">Layout Configuration</h2> <div class="setting-card"> <h3>Layout Mode</h3> <div class="setting-row"> <div class="setting-info"> <span class="setting-label">Use Classic Layout</span> <span class="setting-desc">Disable tabs and show all settings in one page</span> </div> <label class="toggle-switch"> <input type="checkbox" id="useClassicLayout"> <span class="toggle-slider"></span> </label> </div> </div> <div class="setting-card"> <h3>Tab Arrangement</h3> <p class="setting-desc">Drag to rearrange tabs</p> <div id="tab-arrangement"> <ul id="tab-list" class="sortable-list"> <li data-tab="random" class="tab-item"><i class="fa fa-bars handle"></i> Random</li> <li data-tab="tags" class="tab-item"><i class="fa fa-bars handle"></i> Tags</li> <li data-tab="artists" class="tab-item"><i class="fa fa-bars handle"></i> Artists</li> <li data-tab="characters" class="tab-item"><i class="fa fa-bars handle"></i> Characters</li> <li data-tab="parodies" class="tab-item"><i class="fa fa-bars handle"></i> Parodies</li> <li data-tab="groups" class="tab-item"><i class="fa fa-bars handle"></i> Groups</li> <li data-tab="info" class="tab-item"><i class="fa fa-bars handle"></i> Info</li> <li data-tab="twitter" class="tab-item"><i class="fa fa-bars handle"></i> Twitter</li> <li data-tab="read_manga" class="tab-item"><i class="fa fa-bars handle"></i> Read Manga</li> <li data-tab="quick_nut" class="tab-item"><i class="fa fa-bars handle"></i> Quick Nut</li> </ul> <button type="button" id="resetTabOrder" class="btn btn-secondary" style="margin-top:10px;">Reset Order</button> </div> </div> <div class="setting-card"> <h3>Bookmarks Page Layout</h3> <div id="bookmarks-arrangement"> <ul id="bookmarks-list" class="sortable-list"> <li data-element="bookmarksTitle" class="tab-item"><i class="fa fa-bars handle"></i> Bookmarked Pages Title</li> <li data-element="searchInput" class="tab-item"><i class="fa fa-bars handle"></i> Search Input</li> <li data-element="tagSearchInput" class="tab-item"><i class="fa fa-bars handle"></i> Tag Search Input</li> <li data-element="bookmarksList" class="tab-item"><i class="fa fa-bars handle"></i> Bookmarks List</li> <li data-element="mangaBookmarksTitle" class="tab-item"><i class="fa fa-bars handle"></i> Manga Bookmarks Title</li> <li data-element="mangaBookmarksList" class="tab-item"><i class="fa fa-bars handle"></i> Manga Bookmarks List</li> </ul> <button type="button" id="resetBookmarksOrder" class="btn btn-secondary" style="margin-top:10px;">Reset Order</button> </div> </div> </div> <div class="save-bar"> <button type="submit" class="btn btn-primary">Save Settings</button> </div> </form> </div> </div> `; // Append settings form to the container $('div.container').append(settingsHtml); const settingsLastTabKey = 'settingsLastTab'; function setActiveSettingsTab(requestedTab) { const fallbackTab = 'general'; const tab = String(requestedTab || '').trim() || fallbackTab; const $navItem = $(`.settings-nav .nav-item[data-tab="${tab}"]`); const $tabContent = $(`#tab-${tab}`); const resolvedTab = ($navItem.length && $tabContent.length) ? tab : fallbackTab; $('.settings-nav .nav-item').removeClass('active'); $(`.settings-nav .nav-item[data-tab="${resolvedTab}"]`).addClass('active'); $('.tab-content').removeClass('active'); $(`#tab-${resolvedTab}`).addClass('active'); return resolvedTab; } $(document).ready(function() { $('.settings-nav .nav-item').on('click', async function() { const target = $(this).data('tab'); const resolvedTab = setActiveSettingsTab(target); if (!$('.settings-wrapper').hasClass('classic-mode')) { await GM.setValue(settingsLastTabKey, resolvedTab); } }); }); // Nhentai Plus+.user.js (2441-2516) // Load settings (async function() { const findSimilarEnabled = await GM.getValue('findSimilarEnabled', true); const englishFilterEnabled = await GM.getValue('englishFilterEnabled', true); const autoLoginEnabled = await GM.getValue('autoLoginEnabled', true); const email = await GM.getValue('email', ''); const password = await GM.getValue('password', ''); const findAltmangaEnabled = await GM.getValue('findAltmangaEnabled', true); const bookmarksEnabled = await GM.getValue('bookmarksEnabled', true); const language = await GM.getValue('randomPrefLanguage', ''); const tags = await GM.getValue('randomPrefTags', []); const pagesMin = await GM.getValue('randomPrefPagesMin', ''); const pagesMax = await GM.getValue('randomPrefPagesMax', ''); const matchAllTags = await GM.getValue('matchAllTags', true); const blacklistedTags = await GM.getValue('blacklistedTags', []); const mustAddTagsEnabled = await GM.getValue('mustAddTagsEnabled', false); const mustAddTags = (await GM.getValue('mustAddTags', [])).map(tag => tag.toLowerCase()); const smartTagEnabled = await GM.getValue('smartTagEnabled', false); const findAltMangaThumbnailEnabled = await GM.getValue('findAltMangaThumbnailEnabled', true); const openInNewTabEnabled = await GM.getValue('openInNewTabEnabled', true); const mangaBookMarkingButtonEnabled = await GM.getValue('mangaBookMarkingButtonEnabled', true); const shareButtonEnabled = await GM.getValue('shareButtonEnabled', true); const receiveSharesEnabled = await GM.getValue('receiveSharesEnabled', true); const receivePopupsEnabled = await GM.getValue('receivePopupsEnabled', true); let inboxPollIntervalMin = await GM.getValue('inboxPollIntervalMin', null); if (inboxPollIntervalMin === null) { const legacySec = await GM.getValue('inboxPollIntervalSec', null); inboxPollIntervalMin = legacySec != null ? Math.max(1, Math.round(Number(legacySec) / 60)) : 5; await GM.setValue('inboxPollIntervalMin', inboxPollIntervalMin); } const mangaBookMarkingType = await GM.getValue('mangaBookMarkingType', 'cover'); const bookmarkArrangementType = await GM.getValue('bookmarkArrangementType', 'default'); const monthFilterEnabled = await GM.getValue('monthFilterEnabled', true); const tooltipsEnabled = await GM.getValue('tooltipsEnabled', true); const galleryCaptionTooltipsEnabled = await GM.getValue('galleryCaptionTooltipsEnabled', true); const mangagroupingenabled = await GM.getValue('mangagroupingenabled', true); const maxMangaPerBookmark = await GM.getValue('maxMangaPerBookmark', 5); const openInNewTabType = await GM.getValue('openInNewTabType', 'background'); const offlineFavoritingEnabled = await GM.getValue('offlineFavoritingEnabled', true); const offlineFavoritesPageEnabled = await GM.getValue('offlineFavoritesPageEnabled', true); const readMangaPageEnabled = await GM.getValue('readMangaPageEnabled', true); const maxReadMangaDisplay = await GM.getValue('maxReadMangaDisplay', 100); const quickNutPageEnabled = await GM.getValue('quickNutPageEnabled', true); const quickNutEnglishOnlyEnabled = await GM.getValue('quickNutEnglishOnlyEnabled', true); const quickNutSkipReadEnabled = await GM.getValue('quickNutSkipReadEnabled', true); const quickNutRefreshMode = await GM.getValue('quickNutRefreshMode', 'daily'); const quickNutCustomMinutes = await GM.getValue('quickNutCustomMinutes', 1440); const nfmPageEnabled = await GM.getValue('nfmPageEnabled', true); // Online Data Sync settings const publicSyncEnabled = await GM.getValue('publicSyncEnabled', false); const privateSyncEnabled = await GM.getValue('privateSyncEnabled', false); const privateStorageUrl = await GM.getValue('privateStorageUrl', ''); const privateApiKey = await GM.getValue('privateApiKey', ''); const autoSyncEnabled = await GM.getValue('autoSyncEnabled', false); const syncInterval = await GM.getValue('syncInterval', 30); const userUUID = await syncSystem.getUserUUID(); const lastSyncUpload = await GM.getValue('lastSyncUpload', null); const lastSyncDownload = await GM.getValue('lastSyncDownload', null); const bookmarksPageEnabled = await GM.getValue('bookmarksPageEnabled', true); const replaceRelatedWithBookmarks = await GM.getValue('replaceRelatedWithBookmarks', true); const enableRelatedFlipButton = await GM.getValue('enableRelatedFlipButton', true); const twitterButtonEnabled = await GM.getValue('twitterButtonEnabled', true); const enableRandomButton = await GM.getValue('enableRandomButton', true); const randomOpenType = await GM.getValue('randomOpenType', 'new-tab'); const profileButtonEnabled = await GM.getValue('profileButtonEnabled', true); const infoButtonEnabled = await GM.getValue('infoButtonEnabled', true); const logoutButtonEnabled = await GM.getValue('logoutButtonEnabled', true); const bookmarkLinkEnabled = await GM.getValue('bookmarkLinkEnabled', true); const findSimilarType = await GM.getValue('findSimilarType', 'immediately'); const showNonEnglish = await GM.getValue('showNonEnglish', 'show'); const showPageNumbersEnabled = await GM.getValue('showPageNumbersEnabled', true); const useClassicLayout = await GM.getValue('useClassicLayout', false); // New Fade & Read settings const markAsReadEnabled = await GM.getValue('markAsReadEnabled', true); const autoMarkReadEnabled = await GM.getValue('autoMarkReadEnabled', true); const hideBlacklistEnabled = await GM.getValue('hideBlacklistEnabled', true); const autoHideBlacklistedTagsEnabled = await GM.getValue('autoHideBlacklistedTagsEnabled', false); const nonEnglishOpacity = await GM.getValue('nonEnglishOpacity', 0.2); const readGalleriesOpacity = await GM.getValue('readGalleriesOpacity', 0.6); // New Tag Management settings const tagWarningEnabled = await GM.getValue('tagWarningEnabled', true); const blacklistTagsList = await GM.getValue('blacklistTagsList', ['scat', 'guro', 'vore', 'ryona', 'snuff']); const warningTagsList = await GM.getValue('warningTagsList', ['ntr', 'netorare', 'cheating', 'ugly bastard', 'mind break']); const favoriteTagsList = await GM.getValue('favoriteTagsList', []); const smartTagOverrides = await GM.getValue('smartTagOverrides', {}); $('#findSimilarEnabled').prop('checked', findSimilarEnabled); $('#find-similar-options').toggle(findSimilarEnabled); $('#showNonEnglishSelect').val(showNonEnglish); $('#showPageNumbersEnabled').prop('checked', showPageNumbersEnabled); $('#useClassicLayout').prop('checked', useClassicLayout); if (useClassicLayout) { $('.settings-wrapper').addClass('classic-mode'); } else { const savedTab = await GM.getValue(settingsLastTabKey, 'general'); const resolvedTab = setActiveSettingsTab(savedTab); await GM.setValue(settingsLastTabKey, resolvedTab); } $('#englishFilterEnabled').prop('checked', englishFilterEnabled); $('#autoLoginEnabled').prop('checked', autoLoginEnabled); $('#email').val(email); $('#password').val(password); $('#findAltmangaEnabled').prop('checked', findAltmangaEnabled); $('#bookmarksEnabled').prop('checked', bookmarksEnabled); $('#pref-language').val(language); $('#pref-tags').val(tags.join(', ')); $('#pref-pages-min').val(pagesMin); $('#pref-pages-max').val(pagesMax); $('#autoLoginCredentials').toggle(autoLoginEnabled); $('#matchAllTags').prop('checked', matchAllTags); $('#blacklisted-tags').val(blacklistedTags.join(', ')); $('#mustAddTagsEnabled').prop('checked', mustAddTagsEnabled); $('#must-add-tags').val(mustAddTags.join(', ')); $('#must-add-tags').prop('disabled', !mustAddTagsEnabled); $('#smartTagEnabled').prop('checked', smartTagEnabled); $('#mustAddTagsEnabled').on('change', function() { $('#must-add-tags').prop('disabled', !$(this).is(':checked')); }); $('#findAltMangaThumbnailEnabled').prop('checked', findAltMangaThumbnailEnabled); $('#openInNewTabEnabled').prop('checked', openInNewTabEnabled); $('#mangaBookMarkingButtonEnabled').prop('checked', mangaBookMarkingButtonEnabled); $('#shareButtonEnabled').prop('checked', shareButtonEnabled); $('#receiveSharesEnabled').prop('checked', receiveSharesEnabled); $('#receivePopupsEnabled').prop('checked', receivePopupsEnabled); $('#inboxPollIntervalMin').val(inboxPollIntervalMin); $('#monthFilterEnabled').prop('checked', monthFilterEnabled); $('#tooltipsEnabled').prop('checked', tooltipsEnabled); $('#galleryCaptionTooltipsEnabled').prop('checked', galleryCaptionTooltipsEnabled); $('#mangagroupingenabled').prop('checked', mangagroupingenabled); $('#max-manga-per-bookmark-slider').val(maxMangaPerBookmark); $('#offlineFavoritingEnabled').prop('checked', offlineFavoritingEnabled); $('#offlineFavoritesPageEnabled').prop('checked', offlineFavoritesPageEnabled); $('#readMangaPageEnabled').prop('checked', readMangaPageEnabled); // Initialize Read Manga Page options $('#max-read-manga-display-slider').val(maxReadMangaDisplay); $('#max-read-manga-display-value').text(maxReadMangaDisplay); $('#read-manga-page-options').toggle(readMangaPageEnabled); $('#quickNutPageEnabled').prop('checked', quickNutPageEnabled); $('#quick-nut-page-options').toggle(quickNutPageEnabled); $('#quickNutEnglishOnlyEnabled').prop('checked', quickNutEnglishOnlyEnabled); $('#quickNutSkipReadEnabled').prop('checked', quickNutSkipReadEnabled); $('#quickNutRefreshMode').val(quickNutRefreshMode); $('#quickNutCustomMinutes').val(quickNutCustomMinutes); $('#quickNutCustomMinutesContainer').toggle(quickNutRefreshMode === 'custom'); $('#nfmPageEnabled').prop('checked', nfmPageEnabled); $('#bookmarksPageEnabled').prop('checked', bookmarksPageEnabled); $('#replaceRelatedWithBookmarks').prop('checked', replaceRelatedWithBookmarks); $('#enableRelatedFlipButton').prop('checked', enableRelatedFlipButton); $('#twitterButtonEnabled').prop('checked', twitterButtonEnabled); $('#enableRandomButton').prop('checked', enableRandomButton); $('#random-open-in-new-tab').prop('checked', randomOpenType === 'new-tab'); $('#random-open-in-current-tab').prop('checked', randomOpenType === 'current-tab'); $('#profileButtonEnabled').prop('checked', profileButtonEnabled); $('#infoButtonEnabled').prop('checked', infoButtonEnabled); $('#logoutButtonEnabled').prop('checked', logoutButtonEnabled); $('#bookmarkLinkEnabled').prop('checked', bookmarkLinkEnabled); $('#open-immediately').prop('checked', findSimilarType === 'immediately'); $('#input-tags').prop('checked', findSimilarType === 'input-tags'); // Populate new Fade & Read settings $('#markAsReadEnabled').prop('checked', markAsReadEnabled); $('#autoMarkReadEnabled').prop('checked', autoMarkReadEnabled); $('#hideBlacklistEnabled').prop('checked', hideBlacklistEnabled); $('#autoHideBlacklistedTagsEnabled').prop('checked', autoHideBlacklistedTagsEnabled); $('#nonEnglishOpacity').val(nonEnglishOpacity); $('#nonEnglishOpacityValue').text(nonEnglishOpacity); $('#readGalleriesOpacity').val(readGalleriesOpacity); $('#readGalleriesOpacityValue').text(readGalleriesOpacity); // Populate new Tag Management settings $('#tagWarningEnabled').prop('checked', tagWarningEnabled); $('#blacklistTags').val(blacklistTagsList.join(', ')); $('#warningTags').val(warningTagsList.join(', ')); $('#favoriteTags').val(favoriteTagsList.join(', ')); if (typeof renderOverridesList === 'function') { renderOverridesList(smartTagOverrides); } // Populate sync settings // Inbox settings expand/collapse const inboxSettingsExpanded = await GM.getValue('inboxSettingsExpanded', false); $('#inbox-settings-content').toggle(inboxSettingsExpanded); $('#inbox-settings h3').toggleClass('expanded', inboxSettingsExpanded); $('#inbox-settings h3').click(async function() { const isExpanded = $('#inbox-settings-content').is(':visible'); $('#inbox-settings-content').slideToggle(); await GM.setValue('inboxSettingsExpanded', !isExpanded); }); // Render existing inbox messages await renderInboxList(); // Manual Inbox check $('#checkInboxNow').on('click', async function() { try { const showPopups = $('#receivePopupsEnabled').prop('checked'); const messages = await fetchInboxOnce(true); if (messages && messages.length) { await appendToInbox(messages); await renderInboxList(); if (showPopups) { for (const msg of messages) { const galleryUrl = msg?.url || (msg?.id ? `https://nhentai.net/g/${msg.id}/` : ''); const content = ` <div style="text-align:left"> <div style="font-weight:600;margin-bottom:6px;">A gallery was shared with you</div> <div style="font-size:12px;color:#666;margin-bottom:6px;">From UUID: ${msg?.fromUUID || 'Unknown'}</div> </div> `; showPopup(content, { buttons: [ { text: 'Open', callback: () => { if (!galleryUrl) return; const popup = window.open(galleryUrl, '_blank'); if (!popup) window.location.href = galleryUrl; } }, { text: 'Dismiss', callback: () => {} } ], timeout: 0 }); } } } else { showPopup('Inbox is up to date.', { timeout: 2000 }); } // Update last poll timestamp to prevent immediate auto-poll after manual check await GM.setValue('lastInboxPollTS', Date.now()); } catch (err) { showPopup(`Inbox check failed: ${err?.message || err}`, { timeout: 3000 }); } }); // Clear Inbox button with confirmation $('#clearInbox').on('click', async function() { const content = ` <div style="text-align:left"> <div style="font-weight:600;margin-bottom:6px;">Clear Inbox</div> <div style="font-size:12px;color:#666;">Are you sure you want to clear all inbox messages locally?</div> </div> `; showPopup(content, { buttons: [ { text: 'Clear', callback: async () => { await GM.setValue('inboxMessages', []); await renderInboxList(); showPopup('Inbox cleared.', { timeout: 1500 }); } }, { text: 'Cancel', callback: () => {} } ], timeout: 0 }); }); $('#publicSyncEnabled').prop('checked', publicSyncEnabled); $('#privateSyncEnabled').prop('checked', privateSyncEnabled); $('#privateStorageUrl').val(privateStorageUrl); $('#privateApiKey').val(privateApiKey); $('#autoSyncEnabled').prop('checked', autoSyncEnabled); $('#syncInterval').val(syncInterval); $('#userUUID').val(userUUID); // Update sync status displays $('#public-last-sync').text(lastSyncUpload ? new Date(lastSyncUpload).toLocaleString() : 'Never'); $('#private-last-sync').text(lastSyncDownload ? new Date(lastSyncDownload).toLocaleString() : 'Never'); // Update autosync status display const lastAutoSync = await GM.getValue('lastAutoSync', null); $('#auto-sync-status').text(lastAutoSync ? `Last auto sync: ${new Date(lastAutoSync).toLocaleString()}` : 'No automatic syncs yet'); // Show/hide sync options based on enabled state $('#public-sync-options').toggle(publicSyncEnabled); $('#private-sync-options').toggle(privateSyncEnabled); // Initialize AutoSync Manager await autoSyncManager.initialize(); // Add event handlers for sync functionality $('#publicSyncEnabled').on('change', function() { $('#public-sync-options').toggle($(this).prop('checked')); }); $('#privateSyncEnabled').on('change', function() { $('#private-sync-options').toggle($(this).prop('checked')); }); // Save private storage credentials when they change (even when hidden) $('#privateStorageUrl').on('input blur', async function() { const url = $(this).val(); await GM.setValue('privateStorageUrl', url); }); $('#privateApiKey').on('input blur', async function() { const apiKey = $(this).val(); await GM.setValue('privateApiKey', apiKey); }); let originalUUID = null; // Store the original UUID when editing starts $('#edit-uuid').on('click', async function() { const isReadonly = $('#userUUID').prop('readonly'); if (isReadonly) { // Store the original UUID when editing begins originalUUID = $('#userUUID').val(); // Enable editing $('#userUUID').prop('readonly', false).css({ 'background': '#333', 'color': '#fff', 'border': '1px solid #666' }); $('#edit-uuid').text('Save'); $('#uuid-edit-warning').show(); $('#regenerate-uuid').prop('disabled', true); } else { // Save the edited UUID const newUUID = $('#userUUID').val().trim().toUpperCase(); // Validate UUID format (5 alphanumeric characters) if (!/^[A-Z0-9]{5}$/.test(newUUID)) { alert('UUID must be exactly 5 alphanumeric characters (A-Z, 0-9)'); $('#userUUID').val(originalUUID); return; } if (newUUID !== originalUUID) { const confirmChange = confirm( `Are you sure you want to change your UUID from "${originalUUID}" to "${newUUID}"?\n\n` + 'This will affect which data you can access from cloud storage. ' + 'Make sure this is the correct UUID for your data.' ); if (!confirmChange) { $('#userUUID').val(originalUUID); return; } await GM.setValue('userUUID', newUUID); // Force update the syncSystem's cached UUID syncSystem.cachedUUID = newUUID; showPopup('UUID updated successfully!'); } else { // Even if UUID is the same, ensure it's saved to storage await GM.setValue('userUUID', newUUID); // Force update the syncSystem's cached UUID syncSystem.cachedUUID = newUUID; showPopup('UUID saved successfully!'); } // Disable editing $('#userUUID').prop('readonly', true).css({ 'background': '#222', 'color': '#ccc', 'border': '1px solid #333' }); $('#edit-uuid').text('Edit'); $('#uuid-edit-warning').hide(); $('#regenerate-uuid').prop('disabled', false); } }); $('#regenerate-uuid').on('click', async function() { if (confirm('Are you sure you want to regenerate your UUID? This will create a new unique identifier and you may lose access to your existing cloud data.')) { const newUUID = syncSystem.generateUUID(); await GM.setValue('userUUID', newUUID); // Force update the syncSystem's cached UUID syncSystem.cachedUUID = newUUID; $('#userUUID').val(newUUID); showPopup('UUID regenerated successfully!'); } }); $('#browse-users').on('click', async function() { try { $('#browse-users').prop('disabled', true).text('Loading...'); // Only work with private sync if (!$('#privateSyncEnabled').prop('checked')) { showPopup('Browse Users is only available for Private Sync. Please enable Private Sync first.'); return; } const url = $('#privateStorageUrl').val(); const apiKey = $('#privateApiKey').val(); if (!url || !apiKey) { showPopup('Please enter both Storage URL and API Key for Private Sync to browse users.'); return; } let allData = null; try { allData = await syncSystem.providers.jsonstorage.download({ url, apiKey }); } catch (error) { showPopup(`Error accessing private storage: ${error.message}`); return; } if (!allData) { showPopup('No data found in private storage.'); return; } // Display available users let usersList = ''; const currentUUID = $('#userUUID').val(); if (allData.users) { // Multi-user format Object.keys(allData.users).forEach(uuid => { const userData = allData.users[uuid]; const isCurrent = uuid === currentUUID; const lastSync = new Date(userData.timestamp).toLocaleString(); const version = userData.version || 'Unknown'; usersList += ` <div style="margin: 5px 0; padding: 8px; background: ${isCurrent ? '#2d5a2d' : '#444'}; border-radius: 3px; cursor: pointer;" onclick="selectUUID('${uuid}')"> <strong>${uuid}</strong> ${isCurrent ? '(Current)' : ''} <br><small>Version: ${version} | Last sync: ${lastSync}</small> </div> `; }); } else if (allData.userUUID) { // Old single-user format const isCurrent = allData.userUUID === currentUUID; const lastSync = new Date(allData.timestamp).toLocaleString(); const version = allData.version || 'Unknown'; usersList = ` <div style="margin: 5px 0; padding: 8px; background: ${isCurrent ? '#2d5a2d' : '#444'}; border-radius: 3px; cursor: pointer;" onclick="selectUUID('${allData.userUUID}')"> <strong>${allData.userUUID}</strong> ${isCurrent ? '(Current)' : ''} <br><small>Version: ${version} | Last sync: ${lastSync}</small> </div> `; } $('#users-list').html(`<p><strong>Source:</strong> Private Sync</p>` + usersList); $('#available-users').show(); } catch (error) { console.error('Error browsing users:', error); showPopup(`Error browsing users: ${error.message}`); } finally { $('#browse-users').prop('disabled', false).text('Browse Users'); } }); $('#close-users-list').on('click', function() { $('#available-users').hide(); }); // Global function to select UUID from the users list window.selectUUID = async function(uuid) { if (confirm(`Switch to UUID "${uuid}"? This will change your current UUID and affect which data you can access.`)) { $('#userUUID').val(uuid); await GM.setValue('userUUID', uuid); // Force update the syncSystem's cached UUID syncSystem.cachedUUID = uuid; $('#available-users').hide(); showPopup(`UUID changed to ${uuid}`); } }; // Manual autosync trigger $('#trigger-auto-sync').on('click', async function() { const button = $(this); const originalText = button.text(); try { button.prop('disabled', true).text('Syncing...'); await autoSyncManager.performAutoSync(); // Update status display const lastAutoSync = await GM.getValue('lastAutoSync', null); $('#auto-sync-status').text(lastAutoSync ? `Last auto sync: ${new Date(lastAutoSync).toLocaleString()}` : 'No automatic syncs yet'); showPopup('Manual autosync completed successfully!'); } catch (error) { console.error('Manual autosync failed:', error); showPopup(`Manual autosync failed: ${error.message}`); } finally { button.prop('disabled', false).text(originalText); } }); // Public sync handlers $('#public-sync-upload').on('click', async function() { await handleSyncOperation('upload', 'public'); }); $('#public-sync-download').on('click', async function() { await handleSyncOperation('download', 'public'); }); // Private sync handlers $('#private-sync-upload').on('click', async function() { await handleSyncOperation('upload', 'private'); }); $('#private-sync-download').on('click', async function() { await handleSyncOperation('download', 'private'); }); // Sync operation handler async function handleSyncOperation(operation, syncType) { const statusElement = $(`#${syncType}-sync-status`); const lastSyncElement = $(`#${syncType}-last-sync`); try { // Disable buttons and show loading $(`#${syncType}-sync-upload, #${syncType}-sync-download`).prop('disabled', true); statusElement.removeClass('success error').addClass('loading').text('Processing...'); let config; if (syncType === 'public') { config = syncSystem.publicConfig; } else { const url = $('#privateStorageUrl').val(); const apiKey = $('#privateApiKey').val(); if (!url || !apiKey) { throw new Error('Please enter both Storage URL and API Key for private sync'); } config = { url, apiKey }; } if (operation === 'upload') { await syncSystem.uploadData('jsonstorage', config); statusElement.removeClass('loading error').addClass('success').text('Upload successful!'); lastSyncElement.text(new Date().toLocaleString()); showPopup('Data uploaded successfully! Your data has been saved to the cloud.'); } else { const result = await syncSystem.downloadData('jsonstorage', config); statusElement.removeClass('loading error').addClass('success').text('Download successful!'); lastSyncElement.text(new Date().toLocaleString()); let message = `Data downloaded successfully! Applied ${result.appliedCount} settings.`; // if (result.allUsers && result.allUsers.length > 1) { // message += `\n\nAvailable user UUIDs in cloud storage: ${result.allUsers.join(', ')}`; // } showPopup(message); // Refresh the page to apply downloaded settings setTimeout(() => { if (confirm('Settings have been updated. Refresh the page to see changes?')) { location.reload(); } }, 2000); } } catch (error) { console.error('Sync operation failed:', error); statusElement.removeClass('loading success').addClass('error').text(`Error: ${error.message}`); showPopup(`Sync failed: ${error.message}`); } finally { // Re-enable buttons $(`#${syncType}-sync-upload, #${syncType}-sync-download`).prop('disabled', false); // Clear status after 5 seconds setTimeout(() => { statusElement.removeClass('success error loading').text(''); }, 5000); } } // Nhentai Plus+.user.js (2522-2535) // Initialize the visibility of the find-similar-options div based on the initial state of the findSimilarEnabled checkbox $('#find-similar-options').toggle(findSimilarEnabled); // Add event listener to toggle the find-similar-options div when the findSimilarEnabled checkbox is changed $('#findSimilarEnabled').on('change', function() { const isChecked = $(this).is(':checked'); $('#find-similar-options').toggle(isChecked); }); // Toggle auto login credentials $('#autoLoginEnabled').on('change', function() { $('#autoLoginCredentials').toggle(this.checked); }); // Add expand/collapse functionality for new page management section // Add expand/collapse functionality for new page management section const pageManagementExpanded = await GM.getValue('pageManagementExpanded', false); $('#page-management-content').toggle(pageManagementExpanded); $('#page-management h3').toggleClass('expanded', pageManagementExpanded); $('#page-management h3').click(async function() { const isExpanded = $(this).hasClass('expanded'); $(this).toggleClass('expanded', !isExpanded); $('#page-management-content').slideToggle(); await GM.setValue('pageManagementExpanded', !isExpanded); }); // Add expand/collapse functionality for Random Hentai Preferences section const randomSettingsExpanded = await GM.getValue('randomSettingsExpanded', false); $('#random-settings-content').toggle(randomSettingsExpanded); $('#random-settings h3').toggleClass('expanded', randomSettingsExpanded); $('#random-settings h3').click(async function() { const isExpanded = $(this).hasClass('expanded'); $(this).toggleClass('expanded', !isExpanded); $('#random-settings-content').slideToggle(); await GM.setValue('randomSettingsExpanded', !isExpanded); }); // Add expand/collapse functionality for Online Data Sync section const onlineSyncExpanded = await GM.getValue('onlineSyncExpanded', true); $('#online-sync-settings-content').toggle(onlineSyncExpanded); $('#online-sync-header').toggleClass('expanded', onlineSyncExpanded); $('#online-sync-header i') .toggleClass('fa-chevron-down', !onlineSyncExpanded) .toggleClass('fa-chevron-up', onlineSyncExpanded); $('#online-sync-header').off('click').on('click', async function() { const isExpanded = $(this).hasClass('expanded'); $(this).toggleClass('expanded', !isExpanded); $('#online-sync-settings-content').slideToggle(); $('#online-sync-header i') .toggleClass('fa-chevron-down', isExpanded) .toggleClass('fa-chevron-up', !isExpanded); await GM.setValue('onlineSyncExpanded', !isExpanded); }); // Show or hide the random options based on the enableRandomButton value if (enableRandomButton) { $('#random-options').show(); } else { $('#random-options').hide(); } // Add an event listener to the enableRandomButton to show or hide the random options $('#enableRandomButton').on('change', function() { if ($(this).is(':checked')) { $('#random-options').show(); } else { $('#random-options').hide(); } }); $('#max-manga-per-bookmark-slider').on('input', function() { const value = parseInt($(this).val()); $('#max-manga-per-bookmark-on-mobile-value').text(value); //GM.setValue('maxMangaPerBookmark', value); }); (async function() { const maxMangaPerBookmark = await GM.getValue('maxMangaPerBookmark', 5); $('#max-manga-per-bookmark-slider').val(maxMangaPerBookmark); $('#max-manga-per-bookmark-on-mobile-value').text(maxMangaPerBookmark); })(); $('.tooltip').toggle(tooltipsEnabled); $('#tooltipsEnabled').on('change', function() { $('.tooltip').toggle(this.checked); }); // Toggle gallery caption tooltips const toggleGalleryCaptionTooltips = (enabled) => { $('.gallery .caption[title]').toggleClass('tooltip-enabled', enabled); }; toggleGalleryCaptionTooltips(galleryCaptionTooltipsEnabled); $('#galleryCaptionTooltipsEnabled').on('change', function() { toggleGalleryCaptionTooltips(this.checked); }); if (findAltMangaThumbnailEnabled){ $('#find-Alt-Manga-Thumbnail-options').show(); } $('#findAltMangaThumbnailEnabled').on('change', function() { if ($(this).prop('checked')) { $('#find-Alt-Manga-Thumbnail-options').show(); } else { $('#find-Alt-Manga-Thumbnail-options').hide(); } }); if(bookmarksPageEnabled){ $('#bookmark-page-options').show(); } $('#bookmarksPageEnabled').on('change', function() { if ($(this).prop('checked')) { $('#bookmark-page-options').show(); } else { $('#bookmark-page-options').hide(); } }); // Show/hide flip button setting based on related bookmarks setting if ($('#replaceRelatedWithBookmarks').prop('checked')) { $('#enableRelatedFlipButton').closest('label').show(); } else { $('#enableRelatedFlipButton').closest('label').hide(); } // Add event listener to toggle flip button setting visibility $('#replaceRelatedWithBookmarks').on('change', function() { if ($(this).prop('checked')) { $('#enableRelatedFlipButton').closest('label').show(); } else { $('#enableRelatedFlipButton').closest('label').hide(); } }); if (mangaBookMarkingButtonEnabled) { $('#manga-bookmarking-options').show(); } if (mangaBookMarkingType === 'cover') { $('#manga-bookmarking-cover').prop('checked', true); } else if (mangaBookMarkingType === 'title') { $('#manga-bookmarking-title').prop('checked', true); } else if (mangaBookMarkingType === 'both') { $('#manga-bookmarking-both').prop('checked', true); } // Initialize bookmark arrangement dropdown $('#bookmark-arrangement-type').val(bookmarkArrangementType); $('#mangaBookMarkingButtonEnabled').on('change', function() { if ($(this).prop('checked')) { $('#manga-bookmarking-options').show(); } else { $('#manga-bookmarking-options').hide(); } }); // Add event listener for Read Manga Page options $('#readMangaPageEnabled').on('change', function() { $('#read-manga-page-options').toggle($(this).is(':checked')); }); // Update the display value for the max read manga slider $('#max-read-manga-display-slider').on('input', function() { const value = parseInt($(this).val()); $('#max-read-manga-display-value').text(value); }); $('#quickNutPageEnabled').on('change', function() { $('#quick-nut-page-options').toggle($(this).is(':checked')); }); $('#quickNutRefreshMode').on('change', function() { const v = $(this).val(); $('#quickNutCustomMinutesContainer').toggle(v === 'custom'); }); $('#showNonEnglishSelect').on('change', async () => { const showNonEnglish = $('#showNonEnglishSelect').val(); await GM.setValue('showNonEnglish', showNonEnglish); applyNonEnglishStyles(); }); // Event handlers for new Fade & Read settings $('#fade-read-settings h3').on('click', function() { $('#fade-read-settings-content').toggle(); $(this).toggleClass('expanded'); }); $('#nonEnglishOpacity').on('input', function() { const value = parseFloat($(this).val()); $('#nonEnglishOpacityValue').text(value); }); $('#readGalleriesOpacity').on('input', function() { const value = parseFloat($(this).val()); $('#readGalleriesOpacityValue').text(value); }); $('#resetFadeSettings').on('click', function() { $('#nonEnglishOpacity').val(0.2); $('#nonEnglishOpacityValue').text('0.2'); $('#readGalleriesOpacity').val(0.6); $('#readGalleriesOpacityValue').text('0.6'); $('#markAsReadEnabled').prop('checked', true); $('#autoMarkReadEnabled').prop('checked', true); $('#hideBlacklistEnabled').prop('checked', true); $('#autoHideBlacklistedTagsEnabled').prop('checked', false); }); // Clear hidden manga list $('#clearHiddenManga').on('click', async function() { if (confirm('Clear all hidden/blacklisted manga?')) { await GM.setValue('hiddenGalleries', []); await GM.setValue('hiddenGalleryTitles', {}); try { if (typeof hideBlacklistSystem !== 'undefined' && hideBlacklistSystem) { hideBlacklistSystem.hiddenGalleries = new Set(); hideBlacklistSystem.applyHiddenFilter && hideBlacklistSystem.applyHiddenFilter(); } } catch (_) { /* ignore */ } if (typeof updateHiddenCount === 'function') updateHiddenCount(); alert('Hidden manga list cleared.'); } }); // Manage hidden manga (compact modal) async function updateHiddenCount() { try { const ids = await GM.getValue('hiddenGalleries', []); const count = Array.isArray(ids) ? ids.length : 0; $('#hiddenMangaCount').text(count ? `(${count})` : ''); } catch (_) { $('#hiddenMangaCount').text(''); } } function cleanPrettyTitle(raw) { let t = String(raw || ''); // Remove any parenthetical or bracketed qualifiers t = t.replace(/\([^)]*\)/g, '').replace(/\[[^\]]*\]/g, ''); // Collapse whitespace t = t.replace(/\s+/g, ' ').trim(); return t; } async function fetchGalleryPrettyTitle(id) { try { const url = `${location.origin}/g/${id}/`; const res = await fetch(url, { credentials: 'include' }); if (!res || !res.ok) return null; const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const prettyEl = doc.querySelector('h1.title .pretty'); if (!prettyEl) return null; const cleaned = cleanPrettyTitle(prettyEl.textContent || ''); return cleaned || null; } catch (_) { return null; } } function renderHiddenRows(entries, titlesMap) { const listEl = document.getElementById('hiddenMangaList'); if (!listEl) return; listEl.innerHTML = ''; if (!entries || entries.length === 0) { listEl.innerHTML = '<p style="font-size:12px;color:#888;">No hidden manga.</p>'; return; } entries.forEach(({ id, title }) => { const row = document.createElement('div'); row.className = 'row'; const nameLink = document.createElement('a'); nameLink.className = 'item-title'; nameLink.href = `/g/${id}/`; nameLink.target = '_blank'; nameLink.rel = 'noopener noreferrer'; nameLink.textContent = (title && String(title).trim()) ? String(title).trim() : 'Loading…'; const idSpan = document.createElement('span'); idSpan.className = 'item-id'; idSpan.textContent = `ID: ${id}`; const actions = document.createElement('div'); actions.className = 'actions'; const btn = document.createElement('button'); btn.className = 'unhide-item'; btn.setAttribute('data-id', String(id)); btn.textContent = 'Unhide'; actions.appendChild(btn); row.appendChild(nameLink); row.appendChild(idSpan); row.appendChild(actions); listEl.appendChild(row); // Fetch and fill pretty title (async () => { const fetched = await fetchGalleryPrettyTitle(id); const finalTitle = (fetched && fetched.trim()) ? fetched.trim() : ((title && String(title).trim()) ? String(title).trim() : String(id)); nameLink.textContent = finalTitle; // Persist fetched title to storage map for future try { if (titlesMap) { titlesMap[String(id)] = finalTitle; await GM.setValue('hiddenGalleryTitles', titlesMap); } } catch (_) { /* ignore */ } })(); }); } $('#manageHiddenManga').on('click', async function() { try { const ids = await GM.getValue('hiddenGalleries', []); const titlesMap = await GM.getValue('hiddenGalleryTitles', {}); const entries = (Array.isArray(ids) ? ids : []).map(id => ({ id: String(id), title: titlesMap && titlesMap[String(id)] })); renderHiddenRows(entries, titlesMap); } catch (_) { renderHiddenRows([], {}); } $('#hiddenMangaModal').css('display', 'flex'); }); $('#closeHiddenMangaModal').on('click', function() { $('#hiddenMangaModal').hide(); }); $('#hiddenMangaModal').on('click', function(e) { if (e.target && e.target.id === 'hiddenMangaModal') { $('#hiddenMangaModal').hide(); } }); // Delegate unhide actions inside modal $('#hiddenMangaList').on('click', '.unhide-item', async function() { const id = String($(this).data('id')); try { if (typeof hideBlacklistSystem !== 'undefined' && hideBlacklistSystem && typeof hideBlacklistSystem.unhideGallery === 'function') { await hideBlacklistSystem.unhideGallery(id); hideBlacklistSystem.applyHiddenFilter && hideBlacklistSystem.applyHiddenFilter(); } else { let ids = await GM.getValue('hiddenGalleries', []); ids = (Array.isArray(ids) ? ids : []).filter(x => String(x) !== id); await GM.setValue('hiddenGalleries', ids); } let titlesMap = await GM.getValue('hiddenGalleryTitles', {}); if (titlesMap && titlesMap[id]) { delete titlesMap[id]; await GM.setValue('hiddenGalleryTitles', titlesMap); } } catch (_) { /* ignore */ } // Update UI $(this).closest('.row').remove(); if ($('#hiddenMangaList .row').length === 0) { $('#hiddenMangaList').html('<p style="font-size:12px;color:#888;">No hidden manga.</p>'); } updateHiddenCount(); }); // Initialize count indicator updateHiddenCount(); // Event handlers for new Tag Management settings $('#tag-management-settings h3').on('click', function() { $('#tag-management-settings-content').toggle(); $(this).toggleClass('expanded'); }); // Open Tag Management modal $('#toggleTagLists').on('click', function() { $('#tagManagementModal').css('display', 'flex'); }); // Close Tag Management modal $('#closeTagManagementModal').on('click', function() { $('#tagManagementModal').hide(); }); $('#tagManagementModal').on('click', function(e) { if (e.target && e.target.id === 'tagManagementModal') { $('#tagManagementModal').hide(); } }); $('#clearFavoriteTags').on('click', async function() { if (confirm('Are you sure you want to clear all favorite tags?')) { await GM.setValue('favoriteTagsList', []); $('#favoriteTags').val(''); } }); $('#resetTagSettings').on('click', function() { $('#blacklistTags').val('scat, guro, vore, ryona, snuff'); $('#warningTags').val('ntr, netorare, cheating, ugly bastard, mind break'); $('#tagWarningEnabled').prop('checked', true); }); // Smart Tag Overrides management async function getOverrides() { try { return await GM.getValue('smartTagOverrides', {}); } catch (_) { return {}; } } async function setOverrides(obj) { await GM.setValue('smartTagOverrides', obj || {}); } function renderOverridesList(overrides) { const container = document.getElementById('overrides-list'); if (!container) return; container.innerHTML = ''; const entries = Object.entries(overrides || {}).sort((a,b)=>a[0].localeCompare(b[0])); if (entries.length === 0) { container.innerHTML = '<p style="font-size:12px; color:#888;">No overrides set yet.</p>'; return; } entries.forEach(([name, type]) => { const row = document.createElement('div'); row.className = 'override-row'; row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '8px'; row.style.margin = '6px 0'; const nameSpan = document.createElement('span'); nameSpan.textContent = name; nameSpan.style.flex = '1'; const sel = document.createElement('select'); ['artist','group','parody','character','tag'].forEach(opt => { const o = document.createElement('option'); o.value = opt; o.textContent = opt; if (opt === type) o.selected = true; sel.appendChild(o); }); sel.addEventListener('change', async () => { const ov = await getOverrides(); ov[name] = sel.value; await setOverrides(ov); }); const del = document.createElement('button'); del.className = 'btn-secondary action-btn-danger'; del.textContent = 'Remove'; del.addEventListener('click', async () => { const ov = await getOverrides(); delete ov[name]; await setOverrides(ov); renderOverridesList(ov); }); row.appendChild(nameSpan); row.appendChild(sel); row.appendChild(del); container.appendChild(row); }); } const addBtn = document.getElementById('add-override-btn'); if (addBtn) { addBtn.addEventListener('click', async () => { const nameInput = document.getElementById('override-name-input'); const typeSelect = document.getElementById('override-type-select'); const rawName = (nameInput?.value || '').trim(); const type = typeSelect?.value || 'tag'; if (!rawName) return; // Normalize like taxonomyManager const name = String(rawName).toLowerCase().replace(/-/g,' ').trim(); const ov = await getOverrides(); ov[name] = type; await setOverrides(ov); renderOverridesList(ov); nameInput.value = ''; }); } // Check if openInNewTabEnabled is true, if not, hide the options if (openInNewTabEnabled) { $('#open-in-New-Tab-options').show(); } // Add event listeners to the radio buttons $('#open-in-new-tab-background').change(function() { if (this.checked) { GM.setValue('openInNewTabType', 'background'); } }); $('#open-in-new-tab-foreground').change(function() { if (this.checked) { GM.setValue('openInNewTabType', 'foreground'); } }); // Initialize the radio buttons based on the stored value if (openInNewTabType === 'background') { $('#open-in-new-tab-background').prop('checked', true); } else { $('#open-in-new-tab-foreground').prop('checked', true); } // Update the openInNewTabEnabled value in storage when the checkbox is changed $('#openInNewTabEnabled').change(function() { const openInNewTabEnabled = this.checked; GM.setValue('openInNewTabEnabled', openInNewTabEnabled); if (!openInNewTabEnabled) { GM.setValue('openInNewTabType', null); } $('#open-in-New-Tab-options').toggle(openInNewTabEnabled); }); })(); // Save settings $('#settingsForm').on('submit', async function(event) { event.preventDefault(); const findSimilarEnabled = $('#findSimilarEnabled').prop('checked'); const englishFilterEnabled = $('#englishFilterEnabled').prop('checked'); const autoLoginEnabled = $('#autoLoginEnabled').prop('checked'); const email = $('#email').val(); const password = $('#password').val(); const findAltmangaEnabled = $('#findAltmangaEnabled').prop('checked'); const bookmarksEnabled = $('#bookmarksEnabled').prop('checked'); const language = $('#pref-language').val(); let tags = $('#pref-tags').val().split(',').map(tag => tag.trim()); tags = tags.map(tag => tag.replace(/-/g, ' ')); // Replace hyphens with spaces let blacklistedTags = $('#blacklisted-tags').val().split(',').map(tag => tag.trim()); blacklistedTags = blacklistedTags.map(tag => tag.replace(/-/g, ' ')); // Replace hyphens with spaces const mustAddTagsEnabled = $('#mustAddTagsEnabled').is(':checked'); const smartTagEnabled = $('#smartTagEnabled').is(':checked'); let mustAddTags = []; if (mustAddTagsEnabled) { mustAddTags = $('#must-add-tags').val().split(',').map(tag => tag.trim()); mustAddTags = mustAddTags.map(tag => tag.replace(/-/g, ' ')); // Replace hyphens with spaces } const pagesMin = $('#pref-pages-min').val(); const pagesMax = $('#pref-pages-max').val(); const matchAllTags = $('#matchAllTags').prop('checked'); const findAltMangaThumbnailEnabled = $('#findAltMangaThumbnailEnabled').prop('checked'); const openInNewTabEnabled = $('#openInNewTabEnabled').prop('checked'); const mangaBookMarkingButtonEnabled = $('#mangaBookMarkingButtonEnabled').prop('checked'); const shareButtonEnabled = $('#shareButtonEnabled').prop('checked'); const receiveSharesEnabled = $('#receiveSharesEnabled').prop('checked'); const receivePopupsEnabled = $('#receivePopupsEnabled').prop('checked'); const inboxPollIntervalMin = parseInt($('#inboxPollIntervalMin').val(), 10) || 5; const mangaBookMarkingType = $('input[name="manga-bookmarking-type"]:checked').val(); const bookmarkArrangementType = $('#bookmark-arrangement-type').val(); const monthFilterEnabled = $('#monthFilterEnabled').prop('checked'); const tooltipsEnabled = $('#tooltipsEnabled').prop('checked'); const galleryCaptionTooltipsEnabled = $('#galleryCaptionTooltipsEnabled').prop('checked'); const mangagroupingenabled = $('#mangagroupingenabled').prop('checked'); const maxMangaPerBookmark = parseInt($('#max-manga-per-bookmark-slider').val()); const openInNewTabType = $('input[name="open-in-new-tab"]:checked').val(); const offlineFavoritingEnabled = $('#offlineFavoritingEnabled').prop('checked'); const offlineFavoritesPageEnabled = $('#offlineFavoritesPageEnabled').prop('checked'); const readMangaPageEnabled = $('#readMangaPageEnabled').prop('checked'); const maxReadMangaDisplay = parseInt($('#max-read-manga-display-slider').val()); const quickNutPageEnabled = $('#quickNutPageEnabled').prop('checked'); const quickNutEnglishOnlyEnabled = $('#quickNutEnglishOnlyEnabled').prop('checked'); const quickNutSkipReadEnabled = $('#quickNutSkipReadEnabled').prop('checked'); const quickNutRefreshMode = $('#quickNutRefreshMode').val(); const quickNutCustomMinutes = parseInt($('#quickNutCustomMinutes').val()) || 1440; const nfmPageEnabled = $('#nfmPageEnabled').prop('checked'); const bookmarksPageEnabled = $('#bookmarksPageEnabled').prop('checked'); const replaceRelatedWithBookmarks = $('#replaceRelatedWithBookmarks').prop('checked'); const enableRelatedFlipButton = $('#enableRelatedFlipButton').prop('checked'); const twitterButtonEnabled = $('#twitterButtonEnabled').prop('checked'); const enableRandomButton = $('#enableRandomButton').prop('checked'); const randomOpenType = $('input[name="random-open-type"]:checked').val(); const profileButtonEnabled = $('#profileButtonEnabled').prop('checked'); const infoButtonEnabled = $('#infoButtonEnabled').prop('checked'); const logoutButtonEnabled = $('#logoutButtonEnabled').prop('checked'); const bookmarkLinkEnabled = $('#bookmarkLinkEnabled').prop('checked'); const findSimilarType = $('input[name="find-similar-type"]:checked').val(); const showNonEnglish = $('#showNonEnglishSelect').val(); const showPageNumbersEnabled = $('#showPageNumbersEnabled').prop('checked'); const useClassicLayout = $('#useClassicLayout').prop('checked'); // Collect new Fade & Read settings const markAsReadEnabled = $('#markAsReadEnabled').prop('checked'); const autoMarkReadEnabled = $('#autoMarkReadEnabled').prop('checked'); const hideBlacklistEnabled = $('#hideBlacklistEnabled').prop('checked'); const autoHideBlacklistedTagsEnabled = $('#autoHideBlacklistedTagsEnabled').prop('checked'); const nonEnglishOpacity = parseFloat($('#nonEnglishOpacity').val()); const readGalleriesOpacity = parseFloat($('#readGalleriesOpacity').val()); // Collect new Tag Management settings const tagWarningEnabled = $('#tagWarningEnabled').prop('checked'); let blacklistTagsList = $('#blacklistTags').val().split(',').map(tag => tag.trim().toLowerCase()).filter(tag => tag); let warningTagsList = $('#warningTags').val().split(',').map(tag => tag.trim().toLowerCase()).filter(tag => tag); let favoriteTagsList = $('#favoriteTags').val().split(',').map(tag => tag.trim().toLowerCase()).filter(tag => tag); // Collect sync settings const publicSyncEnabledForm = $('#publicSyncEnabled').prop('checked'); const privateSyncEnabledForm = $('#privateSyncEnabled').prop('checked'); const privateStorageUrlForm = $('#privateStorageUrl').val(); const privateApiKeyForm = $('#privateApiKey').val(); const autoSyncEnabledForm = $('#autoSyncEnabled').prop('checked'); const syncIntervalForm = parseInt($('#syncInterval').val()); await GM.setValue('showNonEnglish', showNonEnglish); await GM.setValue('showPageNumbersEnabled', showPageNumbersEnabled); await GM.setValue('useClassicLayout', useClassicLayout); await GM.setValue('findSimilarEnabled', findSimilarEnabled); await GM.setValue('englishFilterEnabled', englishFilterEnabled); await GM.setValue('autoLoginEnabled', autoLoginEnabled); await GM.setValue('email', email); await GM.setValue('password', password); await GM.setValue('findAltmangaEnabled', findAltmangaEnabled); await GM.setValue('bookmarksEnabled', bookmarksEnabled); await GM.setValue('randomPrefLanguage', language); await GM.setValue('blacklistedTags', blacklistedTags); await GM.setValue('mustAddTagsEnabled', mustAddTagsEnabled); await GM.setValue('mustAddTags', mustAddTags); await GM.setValue('smartTagEnabled', smartTagEnabled); await GM.setValue('randomPrefTags', tags); await GM.setValue('randomPrefPagesMin', pagesMin); await GM.setValue('randomPrefPagesMax', pagesMax); await GM.setValue('matchAllTags', matchAllTags); await GM.setValue('findAltMangaThumbnailEnabled', findAltMangaThumbnailEnabled); await GM.setValue('openInNewTabEnabled', openInNewTabEnabled); await GM.setValue('mangaBookMarkingButtonEnabled', mangaBookMarkingButtonEnabled); await GM.setValue('shareButtonEnabled', shareButtonEnabled); await GM.setValue('receiveSharesEnabled', receiveSharesEnabled); await GM.setValue('receivePopupsEnabled', receivePopupsEnabled); await GM.setValue('inboxPollIntervalMin', inboxPollIntervalMin); await GM.setValue('mangaBookMarkingType', mangaBookMarkingType); await GM.setValue('bookmarkArrangementType', bookmarkArrangementType); await GM.setValue('monthFilterEnabled', monthFilterEnabled); await GM.setValue('tooltipsEnabled', tooltipsEnabled); await GM.setValue('galleryCaptionTooltipsEnabled', galleryCaptionTooltipsEnabled); await GM.setValue('mangagroupingenabled', mangagroupingenabled); await GM.setValue('maxMangaPerBookmark', maxMangaPerBookmark); await GM.setValue('openInNewTabType', openInNewTabType); await GM.setValue('offlineFavoritingEnabled', offlineFavoritingEnabled); await GM.setValue('offlineFavoritesPageEnabled', offlineFavoritesPageEnabled); await GM.setValue('readMangaPageEnabled', readMangaPageEnabled); await GM.setValue('maxReadMangaDisplay', maxReadMangaDisplay); await GM.setValue('quickNutPageEnabled', quickNutPageEnabled); await GM.setValue('quickNutEnglishOnlyEnabled', quickNutEnglishOnlyEnabled); await GM.setValue('quickNutSkipReadEnabled', quickNutSkipReadEnabled); await GM.setValue('quickNutRefreshMode', quickNutRefreshMode); await GM.setValue('quickNutCustomMinutes', quickNutCustomMinutes); await GM.setValue('nfmPageEnabled', nfmPageEnabled); await GM.setValue('bookmarksPageEnabled', bookmarksPageEnabled); await GM.setValue('replaceRelatedWithBookmarks', replaceRelatedWithBookmarks); await GM.setValue('enableRelatedFlipButton', enableRelatedFlipButton); await GM.setValue('twitterButtonEnabled', twitterButtonEnabled); await GM.setValue('enableRandomButton', enableRandomButton); await GM.setValue('randomOpenType', randomOpenType); await GM.setValue('profileButtonEnabled', profileButtonEnabled); await GM.setValue('infoButtonEnabled', infoButtonEnabled); await GM.setValue('logoutButtonEnabled', logoutButtonEnabled); await GM.setValue('bookmarkLinkEnabled', bookmarkLinkEnabled); await GM.setValue('findSimilarType', findSimilarType); // Save new Fade & Read settings await GM.setValue('markAsReadEnabled', markAsReadEnabled); await GM.setValue('autoMarkReadEnabled', autoMarkReadEnabled); await GM.setValue('hideBlacklistEnabled', hideBlacklistEnabled); await GM.setValue('autoHideBlacklistedTagsEnabled', autoHideBlacklistedTagsEnabled); await GM.setValue('nonEnglishOpacity', nonEnglishOpacity); await GM.setValue('readGalleriesOpacity', readGalleriesOpacity); // Save new Tag Management settings await GM.setValue('tagWarningEnabled', tagWarningEnabled); await GM.setValue('blacklistTagsList', blacklistTagsList); await GM.setValue('warningTagsList', warningTagsList); await GM.setValue('favoriteTagsList', favoriteTagsList); // Save sync settings await GM.setValue('publicSyncEnabled', publicSyncEnabledForm); await GM.setValue('privateSyncEnabled', privateSyncEnabledForm); await GM.setValue('privateStorageUrl', privateStorageUrlForm); await GM.setValue('privateApiKey', privateApiKeyForm); await GM.setValue('autoSyncEnabled', autoSyncEnabledForm); await GM.setValue('syncInterval', syncIntervalForm); // Update AutoSync Manager with new settings await autoSyncManager.updateSettings(autoSyncEnabledForm, syncIntervalForm); // Show custom popup instead of alert showPopup('Settings saved!'); }); // Import Bookmarked Pages async function importBookmarkedPages(file) { try { const reader = new FileReader(); const fileContent = await new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsText(file); }); const importedBookmarks = JSON.parse(fileContent); if (!Array.isArray(importedBookmarks)) { throw new Error('Invalid file format'); } const existingBookmarks = await GM.getValue('bookmarkedPages', []); const mergedBookmarks = [...new Set([...existingBookmarks, ...importedBookmarks])]; // Merge without duplicates await GM.setValue('bookmarkedPages', mergedBookmarks); alert('Bookmarks imported successfully!'); } catch (error) { alert(`Failed to import bookmarks: ${error.message}`); } } // Add event listeners to buttons on the settings page function setupBookmarkButtons() { // Export Button document.getElementById('exportBookmarks').addEventListener('click', exportBookmarkedPages); // Import Button document.getElementById('importBookmarks').addEventListener('click', () => { document.getElementById('importBookmarksFile').click(); }); // Handle file selection for import document.getElementById('importBookmarksFile').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) { importBookmarkedPages(file); } }); } // Call this function after settings form is rendered setupBookmarkButtons(); //------------------------------------------------ Advanced Settings Management Functions--------------------------------------------------------- // Toggle advanced settings section (Storage Explorer) const advancedHeader = document.getElementById('storage-explorer-header'); const advancedContent = document.getElementById('advanced-settings-content'); if (advancedHeader && advancedContent) { advancedHeader.addEventListener('click', function() { const isHidden = advancedContent.style.display === 'none'; advancedContent.style.display = isHidden ? 'block' : 'none'; // Toggle chevron icon const icon = advancedHeader.querySelector('i'); if (icon) { icon.className = isHidden ? 'fa fa-chevron-up' : 'fa fa-chevron-down'; } if (isHidden) { refreshStorageData(); } }); } // Refresh storage button const refreshBtn = document.getElementById('refresh-storage'); refreshBtn.addEventListener('click', refreshStorageData); // Modal controls const editModal = document.getElementById('edit-value-modal'); const cancelEditBtn = document.getElementById('cancel-edit'); const saveEditBtn = document.getElementById('save-edit'); cancelEditBtn.addEventListener('click', function() { editModal.style.display = 'none'; }); saveEditBtn.addEventListener('click', function() { const keyName = document.getElementById('editing-key-name').dataset.key; const newValue = document.getElementById('edit-value-textarea').value; try { // Try to parse the JSON to validate it const parsedValue = JSON.parse(newValue); // Save the changes to GM storage GM.setValue(keyName, parsedValue) .then(() => { alert('Value saved successfully!'); editModal.style.display = 'none'; refreshStorageData(); }) .catch(err => { alert('Error saving value: ' + err.message); }); } catch (e) { alert('Invalid JSON format. Please check your input.'); } }); // Function to refresh storage data with mobile-friendly layout function refreshStorageData() { const keysList = document.getElementById('storage-keys-list'); keysList.innerHTML = '<p>Loading storage data...</p>'; // Use GM.listValues() to get all keys GM.listValues() .then(keys => { if (keys.length === 0) { keysList.innerHTML = '<p>No data found in storage.</p>'; return; } keysList.innerHTML = ''; // Sort keys alphabetically for easier navigation keys.sort(); // Process each key Promise.all(keys.map(key => { return GM.getValue(key) .then(value => { return { key, value }; }); })) .then(items => { // Create responsive container const container = document.createElement('div'); container.style.width = '100%'; // Add media query detection const isMobile = window.matchMedia("(max-width: 600px)").matches; if (isMobile) { // Mobile view: Card-based layout items.forEach(item => { const card = document.createElement('div'); card.style.border = '1px solid #444'; card.style.borderRadius = '4px'; card.style.padding = '10px'; card.style.marginBottom = '15px'; card.style.backgroundColor = '#2a2a2a'; // Key const keyDiv = document.createElement('div'); keyDiv.style.fontWeight = 'bold'; keyDiv.style.marginBottom = '5px'; keyDiv.style.wordBreak = 'break-word'; keyDiv.textContent = item.key; card.appendChild(keyDiv); // Type and Size const infoDiv = document.createElement('div'); infoDiv.style.display = 'flex'; infoDiv.style.justifyContent = 'space-between'; infoDiv.style.marginBottom = '10px'; infoDiv.style.fontSize = '0.9em'; infoDiv.style.color = '#aaa'; const typeSpan = document.createElement('span'); typeSpan.textContent = `Type: ${getValueType(item.value)}`; const sizeSpan = document.createElement('span'); sizeSpan.textContent = `Size: ${getValueSize(item.value)}`; infoDiv.appendChild(typeSpan); infoDiv.appendChild(sizeSpan); card.appendChild(infoDiv); // Actions const actionDiv = document.createElement('div'); actionDiv.style.display = 'flex'; actionDiv.style.gap = '10px'; const viewBtn = document.createElement('button'); viewBtn.textContent = 'View/Edit'; viewBtn.style.flex = '1'; viewBtn.style.padding = '8px'; viewBtn.style.backgroundColor = '#444'; viewBtn.style.border = 'none'; viewBtn.style.borderRadius = '4px'; viewBtn.style.color = 'white'; viewBtn.style.cursor = 'pointer'; viewBtn.addEventListener('click', function() { openEditModal(item.key, item.value); }); const deleteBtn = document.createElement('button'); deleteBtn.textContent = 'Delete'; deleteBtn.style.flex = '1'; deleteBtn.style.padding = '8px'; deleteBtn.style.backgroundColor = '#d9534f'; deleteBtn.style.border = 'none'; deleteBtn.style.borderRadius = '4px'; deleteBtn.style.color = 'white'; deleteBtn.style.cursor = 'pointer'; deleteBtn.addEventListener('click', function() { if (confirm(`Are you sure you want to delete "${item.key}"?`)) { GM.deleteValue(item.key) .then(() => { refreshStorageData(); }) .catch(err => { alert('Error deleting value: ' + err.message); }); } }); actionDiv.appendChild(viewBtn); actionDiv.appendChild(deleteBtn); card.appendChild(actionDiv); container.appendChild(card); }); } else { // Desktop view: Table layout const table = document.createElement('table'); table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.marginTop = '10px'; // Create table header const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); ['Key', 'Type', 'Size', 'Actions'].forEach(text => { const th = document.createElement('th'); th.textContent = text; th.style.textAlign = 'left'; th.style.padding = '8px'; th.style.backgroundColor = '#2a2a2a'; th.style.borderBottom = '1px solid #444'; headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); // Create table body const tbody = document.createElement('tbody'); items.forEach(item => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid #333'; // Key column const keyCell = document.createElement('td'); keyCell.textContent = item.key; keyCell.style.padding = '8px'; keyCell.style.maxWidth = '200px'; keyCell.style.overflow = 'hidden'; keyCell.style.textOverflow = 'ellipsis'; keyCell.style.whiteSpace = 'nowrap'; // Type column const typeCell = document.createElement('td'); typeCell.textContent = getValueType(item.value); typeCell.style.padding = '8px'; // Size column const sizeCell = document.createElement('td'); sizeCell.textContent = getValueSize(item.value); sizeCell.style.padding = '8px'; // Actions column const actionsCell = document.createElement('td'); actionsCell.style.padding = '8px'; const actionWrapper = document.createElement('div'); actionWrapper.style.display = 'flex'; actionWrapper.style.gap = '5px'; const viewBtn = document.createElement('button'); viewBtn.textContent = 'View/Edit'; viewBtn.style.padding = '3px 8px'; viewBtn.style.backgroundColor = '#444'; viewBtn.style.border = 'none'; viewBtn.style.borderRadius = '2px'; viewBtn.style.color = 'white'; viewBtn.style.cursor = 'pointer'; viewBtn.addEventListener('click', function() { openEditModal(item.key, item.value); }); const deleteBtn = document.createElement('button'); deleteBtn.textContent = 'Delete'; deleteBtn.style.padding = '3px 8px'; deleteBtn.style.backgroundColor = '#d9534f'; deleteBtn.style.border = 'none'; deleteBtn.style.borderRadius = '2px'; deleteBtn.style.color = 'white'; deleteBtn.style.cursor = 'pointer'; deleteBtn.addEventListener('click', function() { if (confirm(`Are you sure you want to delete "${item.key}"?`)) { GM.deleteValue(item.key) .then(() => { refreshStorageData(); }) .catch(err => { alert('Error deleting value: ' + err.message); }); } }); actionWrapper.appendChild(viewBtn); actionWrapper.appendChild(deleteBtn); actionsCell.appendChild(actionWrapper); // Add all cells to the row row.appendChild(keyCell); row.appendChild(typeCell); row.appendChild(sizeCell); row.appendChild(actionsCell); // Add row to table body tbody.appendChild(row); }); table.appendChild(tbody); container.appendChild(table); } keysList.appendChild(container); // Add option to create new key const addNewSection = document.createElement('div'); addNewSection.style.marginTop = '20px'; const addNewHeading = document.createElement('h4'); addNewHeading.textContent = 'Add New Storage Key'; addNewSection.appendChild(addNewHeading); const addNewForm = document.createElement('div'); addNewForm.style.display = 'flex'; addNewForm.style.gap = '10px'; addNewForm.style.marginTop = '10px'; addNewForm.style.flexWrap = 'wrap'; // Allow wrapping on small screens const keyInput = document.createElement('input'); keyInput.type = 'text'; keyInput.placeholder = 'Key name'; keyInput.style.flex = '1'; keyInput.style.padding = '8px'; keyInput.style.backgroundColor = '#333'; keyInput.style.border = '1px solid #444'; keyInput.style.color = '#fff'; keyInput.style.borderRadius = '4px'; keyInput.style.minWidth = '120px'; // Ensure minimum usable width const valueInput = document.createElement('input'); valueInput.type = 'text'; valueInput.placeholder = 'Value (valid JSON)'; valueInput.style.flex = '2'; valueInput.style.padding = '8px'; valueInput.style.backgroundColor = '#333'; valueInput.style.border = '1px solid #444'; valueInput.style.color = '#fff'; valueInput.style.borderRadius = '4px'; valueInput.style.minWidth = '150px'; // Ensure minimum usable width const addBtn = document.createElement('button'); addBtn.textContent = 'Add'; addBtn.style.padding = '8px'; addBtn.style.backgroundColor = '#28a745'; addBtn.style.border = 'none'; addBtn.style.borderRadius = '4px'; addBtn.style.color = 'white'; addBtn.style.cursor = 'pointer'; addBtn.addEventListener('click', function() { const key = keyInput.value.trim(); const value = valueInput.value.trim(); if (!key) { alert('Please enter a key name.'); return; } if (!value) { alert('Please enter a value.'); return; } try { const parsedValue = JSON.parse(value); GM.setValue(key, parsedValue) .then(() => { alert('New key added successfully!'); keyInput.value = ''; valueInput.value = ''; refreshStorageData(); }) .catch(err => { alert('Error adding new key: ' + err.message); }); } catch (e) { alert('Invalid JSON format. Please check your input.'); } }); addNewForm.appendChild(keyInput); addNewForm.appendChild(valueInput); addNewForm.appendChild(addBtn); addNewSection.appendChild(addNewForm); keysList.appendChild(addNewSection); // Add export/import buttons const buttonSection = document.createElement('div'); buttonSection.style.marginTop = '20px'; buttonSection.style.display = 'flex'; buttonSection.style.gap = '10px'; buttonSection.style.flexWrap = 'wrap'; // Allow buttons to wrap on small screens const exportBtn = document.createElement('button'); exportBtn.textContent = 'Export All Storage Data'; exportBtn.style.padding = '10px'; exportBtn.style.backgroundColor = '#007bff'; exportBtn.style.border = 'none'; exportBtn.style.borderRadius = '4px'; exportBtn.style.color = 'white'; exportBtn.style.cursor = 'pointer'; exportBtn.style.flex = '1'; exportBtn.style.minWidth = isMobile ? '100%' : '150px'; exportBtn.addEventListener('click', function() { const exportData = {}; items.forEach(item => { exportData[item.key] = item.value; }); const dataStr = JSON.stringify(exportData, null, 2); const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); const exportLink = document.createElement('a'); exportLink.setAttribute('href', dataUri); exportLink.setAttribute('download', 'userscript_storage_backup.json'); exportLink.click(); }); const importInput = document.createElement('input'); importInput.type = 'file'; importInput.accept = '.json'; importInput.style.display = 'none'; importInput.id = 'import-storage-file'; const importBtn = document.createElement('button'); importBtn.textContent = 'Import Storage Data'; importBtn.style.padding = '10px'; importBtn.style.backgroundColor = '#6c757d'; importBtn.style.border = 'none'; importBtn.style.borderRadius = '4px'; importBtn.style.color = 'white'; importBtn.style.cursor = 'pointer'; importBtn.style.flex = '1'; importBtn.style.minWidth = isMobile ? '100%' : '150px'; importBtn.addEventListener('click', function() { importInput.click(); }); importInput.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { try { const importData = JSON.parse(e.target.result); if (confirm(`This will import ${Object.keys(importData).length} keys. Continue?`)) { // Process each key in the import data const importPromises = Object.entries(importData).map(([key, value]) => { return GM.setValue(key, value); }); Promise.all(importPromises) .then(() => { alert('Import completed successfully!'); refreshStorageData(); }) .catch(err => { alert('Error during import: ' + err.message); }); } } catch (e) { alert('Invalid JSON file. Please check the file format.'); } }; reader.readAsText(file); }); buttonSection.appendChild(exportBtn); buttonSection.appendChild(importBtn); buttonSection.appendChild(importInput); keysList.appendChild(buttonSection); }) .catch(err => { keysList.innerHTML = `<p>Error processing storage data: ${err.message}</p>`; }); }) .catch(err => { keysList.innerHTML = `<p>Error loading storage data: ${err.message}</p>`; }); } // Function to open the edit modal function openEditModal(key, value) { const editModal = document.getElementById('edit-value-modal'); const keyNameElem = document.getElementById('editing-key-name'); const valueTextarea = document.getElementById('edit-value-textarea'); keyNameElem.textContent = `Key: ${key}`; keyNameElem.dataset.key = key; // Format the JSON for better readability const formattedValue = JSON.stringify(value, null, 2); valueTextarea.value = formattedValue; editModal.style.display = 'block'; } // Helper function to get the type of a value function getValueType(value) { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; return typeof value; } // Helper function to get the size of a value function getValueSize(value) { const json = JSON.stringify(value); const bytes = new Blob([json]).size; if (bytes < 1024) { return bytes + ' bytes'; } else if (bytes < 1024 * 1024) { return (bytes / 1024).toFixed(2) + ' KB'; } else { return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } } } // Initialize tab arrangement functionality initializeTabSorting(); updateMenuOrder(); // Initialize Bookmarks Page Arrangement functionality initializeBookmarksSorting(); // Initialize bookmarks page order from storage or use default order async function initializeBookmarksOrder() { const defaultOrder = ['bookmarksTitle', 'searchInput', 'tagSearchInput', 'bookmarksList', 'mangaBookmarksTitle', 'mangaBookmarksList']; const savedOrder = await GM.getValue('bookmarksContainerOrder'); return savedOrder || defaultOrder; } // Function to initialize the bookmarks page sorting functionality function initializeBookmarksSorting() { const bookmarksList = document.getElementById('bookmarks-list'); if (!bookmarksList) return; // Initialize bookmarks list with saved order initializeBookmarksOrder().then(bookmarksOrder => { // Reorder the list items according to the saved order const listItems = Array.from(bookmarksList.children); const tempContainer = document.createDocumentFragment(); bookmarksOrder.forEach(elementName => { const item = listItems.find(li => li.dataset.element === elementName); if (item) tempContainer.appendChild(item); }); // Clear the list and add all items in the new order while (bookmarksList.firstChild) { bookmarksList.removeChild(bookmarksList.firstChild); } bookmarksList.appendChild(tempContainer); }); // Initialize Sortable.js for Bookmarks Page Arrangement new Sortable(bookmarksList, { animation: 150, handle: '.handle', ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', forceFallback: true, fallbackTolerance: 1, delayOnTouchOnly: false, delay: 0, touchStartThreshold: 1, preventTextSelection: true, onStart: function(evt) { evt.item.classList.add('dragging'); document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; document.body.style.mozUserSelect = 'none'; document.body.style.msUserSelect = 'none'; }, onEnd: async function(evt) { evt.item.classList.remove('dragging'); document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; document.body.style.mozUserSelect = ''; document.body.style.msUserSelect = ''; const newOrder = Array.from(bookmarksList.children).map(item => item.dataset.element); await GM.setValue('bookmarksContainerOrder', newOrder); } }); // Add mouse event listeners to improve drag handle feedback bookmarksList.querySelectorAll('.handle').forEach(handle => { handle.addEventListener('mousedown', () => { handle.style.cursor = 'grabbing'; }); handle.addEventListener('mouseup', () => { handle.style.cursor = 'grab'; }); }); // Reset button handler document.getElementById('resetBookmarksOrder').addEventListener('click', async function() { const defaultOrder = ['bookmarksTitle', 'searchInput', 'tagSearchInput', 'bookmarksList', 'mangaBookmarksTitle', 'mangaBookmarksList']; await GM.setValue('bookmarksContainerOrder', defaultOrder); showPopup('Bookmarks page order reset!', {timeout: 1000}); // Reset visual order in settings const bookmarksList = document.getElementById('bookmarks-list'); defaultOrder.forEach(elementName => { const item = bookmarksList.querySelector(`[data-element="${elementName}"]`); if (item) bookmarksList.appendChild(item); }); }); } // Initialize tab order from storage or use default order async function initializeTabOrder() { const defaultOrder = ['random', 'tags', 'artists', 'characters', 'parodies', 'groups', 'info', 'twitter', 'bookmarks', 'offline_favorites', 'read_manga', 'quick_nut', 'continue_reading', 'settings']; const savedOrder = await GM.getValue('tabOrder'); return savedOrder || defaultOrder; } // Function to update the menu based on tab order async function updateMenuOrder() { const tabOrder = await initializeTabOrder(); const menu = document.querySelector('ul.menu.left'); const dropdown = document.querySelector('ul.dropdown-menu'); if (!menu || !dropdown) return; // Get all menu items (both desktop and injected) const allMenuItems = Array.from(menu.querySelectorAll('li:not(.dropdown)')); // Create a temporary container to hold items during reordering const tempContainer = document.createDocumentFragment(); // Process each tab in the desired order for (const tabId of tabOrder) { // Find the menu item for this tab const menuItem = allMenuItems.find(li => { const link = li.querySelector('a'); if (!link) return false; const href = link.getAttribute('href'); // Special case for Twitter which is an external link if (tabId === 'twitter' && href.includes('twitter.com/nhentaiOfficial')) { return true; } // Special case for Offline Favorites if (tabId === 'offline_favorites' && href.includes('/favorite/')) { return true; } // Regular case for internal links return href.includes(`/${tabId}/`) || (tabId === 'read_manga' && href.includes('/read-manga/')) || (tabId === 'quick_nut' && href.includes('/quick-nut/')); }); // If found, move it to our temporary container if (menuItem) { tempContainer.appendChild(menuItem); } } // Add the dropdown menu item const dropdownItem = menu.querySelector('li.dropdown'); if (dropdownItem) { tempContainer.appendChild(dropdownItem); } // Clear the menu and add all items in the new order while (menu.firstChild) { menu.removeChild(menu.firstChild); } menu.appendChild(tempContainer); // Now update the dropdown menu // Clear the dropdown menu first while (dropdown.firstChild) { dropdown.removeChild(dropdown.firstChild); } // Add items to dropdown in the same order for (const tabId of tabOrder) { // Find the corresponding desktop item const desktopItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); if (!link) return false; const href = link.getAttribute('href'); // Special case for Twitter which is an external link if (tabId === 'twitter' && href.includes('twitter.com/nhentaiOfficial')) { return true; } // Regular case for internal links return href.includes(`/${tabId}/`) || (tabId === 'read_manga' && href.includes('/read-manga/')) || (tabId === 'quick_nut' && href.includes('/quick-nut/')); }); if (desktopItem) { // Clone the link and create a new dropdown item const link = desktopItem.querySelector('a'); if (link) { const dropdownLi = document.createElement('li'); dropdownLi.innerHTML = `<a href="${link.getAttribute('href')}">${link.textContent}</a>`; dropdown.appendChild(dropdownLi); } } } } // Helper function to find the reference item for insertion function findReferenceItem(menu, tabOrder, currentIndex) { // Find the previous item in the order that exists in the menu for (let i = currentIndex - 1; i >= 0; i--) { const prevTabId = tabOrder[i]; const prevItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes(prevTabId); }); if (prevItem) return prevItem; } return null; } // Initialize Sortable.js for tab arrangement function initializeTabSorting() { const tabList = document.getElementById('tab-list'); if (!tabList) return; // Initialize tab list with saved order initializeTabOrder().then(tabOrder => { // First, check if we need to create the dynamic tab items const bookmarksExists = tabOrder.includes('bookmarks') && !tabList.querySelector('[data-tab="bookmarks"]'); const continueReadingExists = tabOrder.includes('continue_reading') && !tabList.querySelector('[data-tab="continue_reading"]'); const settingsExists = tabOrder.includes('settings') && !tabList.querySelector('[data-tab="settings"]'); // Check if these items exist in the actual menu before adding them to the sortable list const menu = document.querySelector('ul.menu.left'); if (menu) { // Only create bookmarks tab item if it exists in the actual menu and not in the DOM const bookmarksInMenu = Array.from(menu.querySelectorAll('li')).some(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/bookmarks/'); }); if (bookmarksInMenu && bookmarksExists) { const bookmarksTabItem = document.createElement('li'); bookmarksTabItem.className = 'tab-item'; bookmarksTabItem.dataset.tab = 'bookmarks'; bookmarksTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Bookmarks'; tabList.appendChild(bookmarksTabItem); } // Only create continue reading tab item if it exists in the actual menu and not in the DOM const continueReadingInMenu = Array.from(menu.querySelectorAll('li')).some(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/continue_reading/'); }); if (continueReadingInMenu && continueReadingExists) { const continueReadingTabItem = document.createElement('li'); continueReadingTabItem.className = 'tab-item'; continueReadingTabItem.dataset.tab = 'continue_reading'; continueReadingTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Continue Reading'; tabList.appendChild(continueReadingTabItem); } // Only create settings tab item if it exists in the actual menu and not in the DOM const settingsInMenu = Array.from(menu.querySelectorAll('li')).some(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/settings/'); }); if (settingsInMenu && settingsExists) { const settingsTabItem = document.createElement('li'); settingsTabItem.className = 'tab-item'; settingsTabItem.dataset.tab = 'settings'; settingsTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Settings'; tabList.appendChild(settingsTabItem); } // Check if we need to add the offline favorites tab (only if user is not logged in) const isLoggedIn = !document.querySelector('.menu-sign-in'); const offlineFavoritesExists = tabOrder.includes('offline_favorites') && !tabList.querySelector('[data-tab="offline_favorites"]'); if (offlineFavoritesExists && !isLoggedIn) { const offlineFavoritesTabItem = document.createElement('li'); offlineFavoritesTabItem.className = 'tab-item'; offlineFavoritesTabItem.dataset.tab = 'offline_favorites'; offlineFavoritesTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Offline Favorites'; tabList.appendChild(offlineFavoritesTabItem); } } // Now reorder all tabs according to the saved order tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); // Check for dynamically added menu items and add them to the tab list function checkForDynamicItems() { const menu = document.querySelector('ul.menu.left'); if (!menu) return; // Check for Bookmarks const bookmarksItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/bookmarks/'); }); if (bookmarksItem && !tabList.querySelector('[data-tab="bookmarks"]')) { const bookmarksTabItem = document.createElement('li'); bookmarksTabItem.className = 'tab-item'; bookmarksTabItem.dataset.tab = 'bookmarks'; bookmarksTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Bookmarks'; tabList.appendChild(bookmarksTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } // Check for Continue Reading const continueReadingItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/continue_reading/'); }); if (continueReadingItem && !tabList.querySelector('[data-tab="continue_reading"]')) { const continueReadingTabItem = document.createElement('li'); continueReadingTabItem.className = 'tab-item'; continueReadingTabItem.dataset.tab = 'continue_reading'; continueReadingTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Continue Reading'; tabList.appendChild(continueReadingTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } // Check for Info const infoItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/info/'); }); if (infoItem && !tabList.querySelector('[data-tab="info"]')) { const infoTabItem = document.createElement('li'); infoTabItem.className = 'tab-item'; infoTabItem.dataset.tab = 'info'; infoTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Info'; tabList.appendChild(infoTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } // Check for Twitter const twitterItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('twitter.com/nhentaiOfficial'); }); if (twitterItem && !tabList.querySelector('[data-tab="twitter"]')) { const twitterTabItem = document.createElement('li'); twitterTabItem.className = 'tab-item'; twitterTabItem.dataset.tab = 'twitter'; twitterTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Twitter'; tabList.appendChild(twitterTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } // Check for Settings const settingsItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/settings/'); }); if (settingsItem && !tabList.querySelector('[data-tab="settings"]')) { const settingsTabItem = document.createElement('li'); settingsTabItem.className = 'tab-item'; settingsTabItem.dataset.tab = 'settings'; settingsTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Settings'; tabList.appendChild(settingsTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } // Check for Offline Favorites const offlineFavoritesItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/favorite/'); }); if (offlineFavoritesItem && !tabList.querySelector('[data-tab="offline_favorites"]')) { // Check if user is logged in const isLoggedIn = !document.querySelector('.menu-sign-in'); // Only add the offline favorites tab if the user is not logged in if (!isLoggedIn) { const offlineFavoritesTabItem = document.createElement('li'); offlineFavoritesTabItem.className = 'tab-item'; offlineFavoritesTabItem.dataset.tab = 'offline_favorites'; offlineFavoritesTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Offline Favorites'; tabList.appendChild(offlineFavoritesTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } } // Check for Read Manga const readMangaItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/read-manga/'); }); if (readMangaItem && !tabList.querySelector('[data-tab="read_manga"]')) { const readMangaTabItem = document.createElement('li'); readMangaTabItem.className = 'tab-item'; readMangaTabItem.dataset.tab = 'read_manga'; readMangaTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Read Manga'; tabList.appendChild(readMangaTabItem); // Reapply the saved order after adding a new item initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } const quickNutItem = Array.from(menu.querySelectorAll('li')).find(li => { const link = li.querySelector('a'); return link && link.getAttribute('href').includes('/quick-nut/'); }); if (quickNutItem && !tabList.querySelector('[data-tab="quick_nut"]')) { const quickNutTabItem = document.createElement('li'); quickNutTabItem.className = 'tab-item'; quickNutTabItem.dataset.tab = 'quick_nut'; quickNutTabItem.innerHTML = '<i class="fa fa-bars handle"></i> Quick Nut'; tabList.appendChild(quickNutTabItem); initializeTabOrder().then(tabOrder => { tabOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); }); } } // Check for dynamic items initially and then every second checkForDynamicItems(); setInterval(checkForDynamicItems, 1000); new Sortable(tabList, { animation: 150, handle: '.handle', ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', forceFallback: true, fallbackTolerance: 1, delayOnTouchOnly: false, delay: 0, touchStartThreshold: 1, preventTextSelection: true, onStart: function(evt) { evt.item.classList.add('dragging'); document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; document.body.style.mozUserSelect = 'none'; document.body.style.msUserSelect = 'none'; }, onEnd: async function(evt) { evt.item.classList.remove('dragging'); document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; document.body.style.mozUserSelect = ''; document.body.style.msUserSelect = ''; const newOrder = Array.from(tabList.children).map(item => item.dataset.tab); await GM.setValue('tabOrder', newOrder); updateMenuOrder(); } }); // Add mouse event listeners to improve drag handle feedback tabList.querySelectorAll('.handle').forEach(handle => { handle.addEventListener('mousedown', () => { handle.style.cursor = 'grabbing'; }); handle.addEventListener('mouseup', () => { handle.style.cursor = 'grab'; }); }); // Reset button handler document.getElementById('resetTabOrder').addEventListener('click', async function() { const defaultOrder = ['random', 'tags', 'artists', 'characters', 'parodies', 'groups', 'info', 'twitter', 'bookmarks', 'offline_favorites', 'read_manga', 'quick_nut', 'continue_reading', 'settings']; await GM.setValue('tabOrder', defaultOrder); showPopup('Tab order reset!', {timeout: 1000}); // Reset visual order in settings const tabList = document.getElementById('tab-list'); defaultOrder.forEach(tabId => { const item = tabList.querySelector(`[data-tab="${tabId}"]`); if (item) tabList.appendChild(item); }); updateMenuOrder(); }); } // Function to check if the menu is in the correct order async function isMenuInOrder() { // console.log("Checking if menu is in order..."); const menu = document.querySelector('ul.menu.left'); if (!menu) return false; // console.log("Menu:", menu); const tabOrder = await initializeTabOrder(); // Wait for the promise to resolve // console.log("Tab order:", tabOrder); // Get all menu items except dropdown in their DOM order const allMenuItems = Array.from(menu.querySelectorAll('li:not(.dropdown)')); // console.log("All menu items:", allMenuItems); // Create a map of tab IDs to their desired position const tabPositions = {}; tabOrder.forEach((tabId, index) => { tabPositions[tabId] = index; }); // Extract the tab IDs from the DOM in order const currentTabOrder = []; for (const menuItem of allMenuItems) { const link = menuItem.querySelector('a'); if (link) { const href = link.getAttribute('href'); // Special case for Twitter which is an external link if (href.includes('twitter.com/nhentaiOfficial')) { currentTabOrder.push('twitter'); continue; } // Special case for Offline Favorites if (href.includes('/favorite/')) { currentTabOrder.push('offline_favorites'); continue; } if (href.includes('/read-manga/')) { currentTabOrder.push('read_manga'); continue; } if (href.includes('/quick-nut/')) { currentTabOrder.push('quick_nut'); continue; } // Extract the tab ID from the href for internal links const match = href.match(/\/([^\/]+)\//); if (match && match[1]) { currentTabOrder.push(match[1]); } } } // console.log("Current tab order from DOM:", currentTabOrder); // console.log("Desired tab order:", tabOrder); // Get the tabs that are actually present in the menu const presentTabs = tabOrder.filter(tabId => currentTabOrder.includes(tabId)); // If no tabs from the order are present, consider it in order to avoid constant updates if (presentTabs.length === 0) { return true; } // Check if the offline favorites tab is the only one missing const missingTabs = tabOrder.filter(tabId => !currentTabOrder.includes(tabId)); if (missingTabs.length === 1 && missingTabs[0] === 'offline_favorites') { // If only the offline favorites tab is missing, consider the menu in order return true; } // Check if there are other important tabs missing const importantMissingTabs = missingTabs.filter(tabId => tabId !== 'offline_favorites' && tabId !== 'twitter' && tabId !== 'info' ); if (importantMissingTabs.length > 0) { // If important tabs are missing, the menu is not in order return false; } // Now check if the relative order is correct for the tabs that exist // Skip tabs that don't exist in the current DOM let lastFoundIndex = -1; for (const tabId of tabOrder) { const currentIndex = currentTabOrder.indexOf(tabId); if (currentIndex !== -1) { // If this tab exists in the DOM, it should come after the last found tab if (currentIndex < lastFoundIndex) { console.log(`Tab ${tabId} is out of order: found at ${currentIndex}, should be after ${lastFoundIndex}`); return false; } lastFoundIndex = currentIndex; } } // If we get here, all existing tabs are in the correct relative order // console.log("Menu is in correct order"); return true; } // Call updateMenuOrder only when the menu is not in the correct order // Use a longer interval to reduce constant updates setInterval(async () => { if (!await isMenuInOrder()) { updateMenuOrder(); } }, 1000); //------------------------------------------------ Advanced Settings Management Functions--------------------------------------------------------- function showPopup(message, options = {}) { // Default options const defaultOptions = { timeout: 3000, // Default timeout of 3 seconds width: '250px', // Default width buttons: [], // Additional buttons besides close closeButton: true, // Show close button autoClose: true // Auto close after timeout }; // Merge default options with provided options const settings = { ...defaultOptions, ...options }; // Create popup element const popup = document.createElement('div'); popup.id = 'popup'; // Create buttons HTML if provided let buttonsHTML = ''; if (settings.buttons && settings.buttons.length > 0) { buttonsHTML = '<div class="popup-buttons">'; settings.buttons.forEach(button => { buttonsHTML += `<button class="popup-btn" data-action="${button.action || ''}">${button.text}</button>`; }); buttonsHTML += '</div>'; } // Create close button HTML if enabled const closeButtonHTML = settings.closeButton ? '<button class="close-btn">×</button>' : ''; // Populate popup HTML popup.innerHTML = ` <div class="popup-content"> ${closeButtonHTML} <p>${message}</p> ${buttonsHTML} </div> `; document.body.appendChild(popup); // Add CSS styling for the popup const style = document.createElement('style'); style.textContent = ` #popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.9); color: #fff; border-radius: 5px; z-index: 9999; padding: 15px; width: ${settings.width}; text-align: center; } .popup-content { position: relative; padding: 10px; } .close-btn { position: absolute; top: 5px; right: 10px; background: none; border: none; color: #fff; font-size: 18px; cursor: pointer; transition: color 0.3s, opacity 0.3s; } .close-btn:hover { color: #ff0000; opacity: 0.7; } .popup-buttons { margin-top: 15px; display: flex; justify-content: center; gap: 10px; } .popup-btn { background: #333; color: #fff; border: 1px solid #555; border-radius: 3px; padding: 5px 10px; cursor: pointer; transition: background 0.3s; } .popup-btn:hover { background: #444; } `; document.head.appendChild(style); // Function to close the popup const closePopup = () => { if (document.body.contains(popup)) { document.body.removeChild(popup); document.head.removeChild(style); } }; // Close button event listener if (settings.closeButton) { const closeBtn = popup.querySelector('.close-btn'); if (closeBtn) { closeBtn.addEventListener('click', closePopup); } } // Add event listeners for custom buttons if (settings.buttons && settings.buttons.length > 0) { const buttons = popup.querySelectorAll('.popup-btn'); buttons.forEach((btn, index) => { btn.addEventListener('click', (e) => { // Execute the callback if provided if (settings.buttons[index].callback && typeof settings.buttons[index].callback === 'function') { settings.buttons[index].callback(e); } // Close the popup after button click if closeOnClick is true if (settings.buttons[index].closeOnClick !== false) { closePopup(); } }); }); } // Auto-close the popup after the specified timeout let timeoutId; if (settings.autoClose && settings.timeout > 0) { timeoutId = setTimeout(closePopup, settings.timeout); } // Return an object with methods to control the popup return { close: closePopup, updateMessage: (newMessage) => { const messageElement = popup.querySelector('p'); if (messageElement) { messageElement.innerHTML = newMessage; } }, resetTimeout: () => { if (timeoutId) { clearTimeout(timeoutId); } if (settings.autoClose && settings.timeout > 0) { timeoutId = setTimeout(closePopup, settings.timeout); } } }; } function exportBookmarkedPages() { GM.getValue('bookmarkedPages', []).then(bookmarkedPages => { const blob = new Blob([JSON.stringify(bookmarkedPages, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'bookmarked_pages.json'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); } //----------------------------**Settings**-------------------------------------------- //----------------------------**Random Hentai Preferences**---------------------------- // Intercept random button clicks only if preferences are set document.addEventListener('click', async function(event) { const target = event.target; if (target.tagName === 'A' && target.getAttribute('href') === '/random/') { event.preventDefault(); // Prevent the default navigation // Show the loading popup immediately showLoadingPopup(); // Check if user preferences are set const preferencesSet = await arePreferencesSet(); if (preferencesSet) { // Set a flag to stop the search if needed window.searchInProgress = true; fetchRandomHentai(); } else { // Close the popup and proceed with the default action hideLoadingPopup(); window.location.href = '/random/'; } } }); async function arePreferencesSet() { try { const language = await GM.getValue('randomPrefLanguage', ''); const tags = await GM.getValue('randomPrefTags', []); const pagesMin = parseInt(await GM.getValue('randomPrefPagesMin', ''), 10); const pagesMax = parseInt(await GM.getValue('randomPrefPagesMax', ''), 10); // Language check works the same whether it's a single language or multiple comma-separated languages return language || tags.length > 0 || !isNaN(pagesMin) || !isNaN(pagesMax); } catch (error) { console.error('Error checking preferences:', error); return false; } } function showLoadingPopup() { if (window.searchInProgress) { showPopup('Already searching for random content!'); return; } // Create and display the popup const popup = document.createElement('div'); popup.id = 'loading-popup'; popup.style.position = 'fixed'; popup.style.top = '50%'; popup.style.left = '50%'; popup.style.transform = 'translate(-50%, -50%)'; popup.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; popup.style.color = 'white'; popup.style.padding = '20px'; popup.style.borderRadius = '8px'; popup.style.zIndex = '9999'; popup.style.display = 'flex'; popup.style.flexDirection = 'column'; popup.style.alignItems = 'center'; popup.style.justifyContent = 'center'; // Popup content with image container and buttons popup.innerHTML = ` <span>Searching for random content...</span> <div id="cover-preview-container" style="margin-top: 10px; width: 350px; height: 192px; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 8px;"> <a id="cover-preview-link" href="#" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; text-decoration: none;"> <img id="cover-preview" style="max-width: 100%; max-height: 100%; object-fit: contain; display: none; cursor: pointer;" /> </a> </div> <div id="preview-notes" style="margin-top: 10px; color: white; text-align: center;"> <!-- Notes will be inserted here --> </div> <div style="margin-top: 20px; display: flex; gap: 15px;"> <button id="previous-image" class="control-button" style="background: none; border: none; color: white; cursor: pointer; font-size: 20px; transition: color 0.3s ease, transform 0.3s ease;"> <i class="fas fa-arrow-left"></i> </button> <button id="pause-search" class="control-button" style="background: none; border: none; color: white; cursor: pointer; font-size: 20px; transition: color 0.3s ease, transform 0.3s ease;"> <i class="fas fa-pause"></i> </button> <button id="next-image" class="control-button" style="background: none; border: none; color: white; cursor: pointer; font-size: 20px; transition: color 0.3s ease, transform 0.3s ease;"> <i class="fas fa-arrow-right"></i> </button> </div> <button class="close" style="margin-top: 20px; background: none; border: none; font-size: 24px; color: white; cursor: pointer;">×</button> `; document.body.appendChild(popup); // Add event listener to close button const closeButton = popup.querySelector('.close'); closeButton.addEventListener('click', function() { hideLoadingPopup(); window.searchInProgress = false; // Stop the search }); // Add hover effect for the close button closeButton.addEventListener('mouseenter', function() { closeButton.style.color = 'red'; closeButton.style.opacity = '0.7'; }); closeButton.addEventListener('mouseleave', function() { closeButton.style.color = 'white'; closeButton.style.opacity = '1'; }); // Add hover effect for control buttons const controlButtons = document.querySelectorAll('.control-button'); controlButtons.forEach(button => { button.addEventListener('mouseenter', function() { button.style.color = '#ddd'; // Light color on hover button.style.transform = 'scale(1.1)'; // Slightly enlarge button }); button.addEventListener('mouseleave', function() { button.style.color = 'white'; // Original color button.style.transform = 'scale(1)'; // Return to original size }); }); // Add event listeners for control buttons document.getElementById('previous-image').addEventListener('click', showPreviousImage); document.getElementById('pause-search').addEventListener('click', togglePause); document.getElementById('next-image').addEventListener('click', showNextImage); // Add click event listener to the preview image to navigate to the content URL document.getElementById('cover-preview').addEventListener('click', function() { const currentImageIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); const images = getImagesFromLocalStorage(); if (images[currentImageIndex] && images[currentImageIndex].url) { window.location.href = images[currentImageIndex].url; } }); } function hideLoadingPopup() { const popup = document.getElementById('loading-popup'); if (popup) { document.body.removeChild(popup); } } async function fetchRandomHentai(retryCount = 0) { try { if (!window.searchInProgress) return; // Stop if search was canceled const response = await fetch('https://nhentai.net/random/', { method: 'HEAD' }); // Handle 429 Too Many Requests error if (response.status === 429) { console.log(`Received 429 Too Many Requests in fetchRandomHentai. Retry attempt: ${retryCount + 1}`); // Calculate wait time with exponential backoff (5s, then 10s max) const waitTime = Math.min(5000 * Math.pow(2, retryCount), 10000); // Show message in loading popup const loadingPopup = document.getElementById('randomLoadingPopup'); if (loadingPopup) { const statusElement = loadingPopup.querySelector('.random-status'); if (statusElement) { statusElement.textContent = `Rate limited. Waiting ${waitTime/1000}s before retry...`; } } // Wait before retrying await new Promise(resolve => setTimeout(resolve, waitTime)); // Max retries (3 attempts total) if (retryCount < 2) { return fetchRandomHentai(retryCount + 1); } else { console.error('Max retries reached for 429 error in fetchRandomHentai'); return; } } await analyzeURL(response.url); } catch (error) { console.error('Error fetching random URL:', error); // Check if error is related to rate limiting if (error.message && error.message.includes('429')) { console.log(`Caught 429 error in fetchRandomHentai. Retry attempt: ${retryCount + 1}`); // Calculate wait time with exponential backoff (5s, then 10s max) const waitTime = Math.min(5000 * Math.pow(2, retryCount), 10000); // Wait before retrying await new Promise(resolve => setTimeout(resolve, waitTime)); // Max retries (3 attempts total) if (retryCount < 2) { return fetchRandomHentai(retryCount + 1); } } } } async function analyzeURL(url, retryCount = 0) { try { if (!window.searchInProgress) { return; // Stop if search was canceled } const response = await fetch(url); // Handle 429 Too Many Requests error with retry mechanism if (response.status === 429) { console.log(`Received 429 Too Many Requests. Retry attempt: ${retryCount + 1}`); // Calculate wait time with exponential backoff (5s, then 10s max) const waitTime = Math.min(5000 * Math.pow(2, retryCount), 10000); // Show message in loading popup const loadingPopup = document.getElementById('randomLoadingPopup'); if (loadingPopup) { const statusElement = loadingPopup.querySelector('.random-status'); if (statusElement) { statusElement.textContent = `Rate limited. Waiting ${waitTime/1000}s before retry...`; } } // Wait before retrying await new Promise(resolve => setTimeout(resolve, waitTime)); // Max retries (3 attempts total) if (retryCount < 2) { return analyzeURL(url, retryCount + 1); } else { console.error('Max retries reached for 429 error'); fetchRandomHentai(); // Try a new random manga return; } } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const coverImage = doc.querySelector('#cover img.lazyload'); const coverImageUrl = coverImage ? (coverImage.getAttribute('data-src') || coverImage.src) : null; const title = doc.querySelector('#info h1')?.textContent.trim(); const tags = Array.from(doc.querySelectorAll('#tags .tag')).map(tag => tag.textContent.trim()); const pages = parseInt(doc.querySelector('#tags .tag-container:nth-last-child(2) .name')?.textContent.trim(), 10); const uploadDate = doc.querySelector('#tags .tag-container:last-child time')?.getAttribute('datetime'); await addKnownMultiwordTags(tags); // Extract and handle languages let languages = []; const tagContainers = doc.querySelectorAll('.tag-container.field-name'); tagContainers.forEach(container => { if (container.textContent.includes('Languages:')) { const languageElements = container.querySelectorAll('.tags .tag .name'); languageElements.forEach(languageElement => { let language = languageElement.textContent.trim().toLowerCase(); languages.push(language); }); } }); // Determine which language to display let languageDisplay = 'Unknown'; if (languages.includes('english')) { languageDisplay = 'English'; } else if (languages.includes('translated') && languages.length === 1) { languageDisplay = 'English'; } else if (languages.includes('translated') && languages.length > 1) { // Exclude 'translated' and show other language(s) const otherLanguages = languages.filter(lang => lang !== 'translated'); languageDisplay = otherLanguages.length > 0 ? otherLanguages.map(lang => lang.charAt(0).toUpperCase() + lang.slice(1)).join(', ') : 'Unknown'; } else { languageDisplay = languages.map(lang => lang.charAt(0).toUpperCase() + lang.slice(1)).join(', '); } if (coverImageUrl) { saveImageToLocalStorage(coverImageUrl, url, languageDisplay, pages, title); showPreviousImage(); } if (await meetsUserPreferences(tags, pages)) { hideLoadingPopup(); window.location.href = url; } else { fetchRandomHentai(); } } catch (error) { console.error('Error analyzing page:', error); // Check if error is related to rate limiting if (error.message && error.message.includes('429')) { console.log(`Caught 429 error. Retry attempt: ${retryCount + 1}`); // Calculate wait time with exponential backoff (5s, then 10s max) const waitTime = Math.min(5000 * Math.pow(2, retryCount), 10000); // Show message in loading popup const loadingPopup = document.getElementById('randomLoadingPopup'); if (loadingPopup) { const statusElement = loadingPopup.querySelector('.random-status'); if (statusElement) { statusElement.textContent = `Rate limited. Waiting ${waitTime/1000}s before retry...`; } } // Wait before retrying await new Promise(resolve => setTimeout(resolve, waitTime)); // Max retries (3 attempts total) if (retryCount < 2) { return analyzeURL(url, retryCount + 1); } } // For other errors or max retries reached, try a new random manga fetchRandomHentai(); } } async function meetsUserPreferences(tags, pages) { try { const preferredLanguageInput = (await GM.getValue('randomPrefLanguage', '')).toLowerCase(); const preferredLanguages = preferredLanguageInput ? preferredLanguageInput.split(',').map(lang => lang.trim().toLowerCase()) : []; const preferredTags = (await GM.getValue('randomPrefTags', [])).map(tag => tag.toLowerCase()); const blacklistedTags = (await GM.getValue('blacklistedTags', [])).map(tag => tag.toLowerCase()).filter(tag => tag !== ''); const preferredPagesMin = parseInt(await GM.getValue('randomPrefPagesMin', ''), 10); const preferredPagesMax = parseInt(await GM.getValue('randomPrefPagesMax', ''), 10); const matchAllTags = await GM.getValue('matchAllTags', true); // Strip tag counts and only keep the tag names const cleanedTags = tags.map(tag => tag.replace(/\d+K?$/, '').trim().toLowerCase()); // Check if any of the preferred languages match let hasPreferredLanguage = true; if (preferredLanguages.length > 0) { hasPreferredLanguage = preferredLanguages.some(lang => cleanedTags.includes(lang)); } let hasPreferredTags; if (preferredTags.length > 0) { if (matchAllTags) { hasPreferredTags = preferredTags.every(tag => cleanedTags.includes(tag)); } else { hasPreferredTags = preferredTags.some(tag => cleanedTags.includes(tag)); } } else { hasPreferredTags = true; } const withinPageRange = (!isNaN(preferredPagesMin) ? pages >= preferredPagesMin : true) && (!isNaN(preferredPagesMax) ? pages <= preferredPagesMax : true); const hasBlacklistedTags = blacklistedTags.some(tag => cleanedTags.includes(tag)); const meetsPreferences = hasPreferredLanguage && hasPreferredTags && withinPageRange && !hasBlacklistedTags; return meetsPreferences; } catch (error) { console.error('Error checking user preferences:', error); return false; } } function saveImageToLocalStorage(imageUrl, hentaiUrl, language, pages, title) { let images = JSON.parse(localStorage.getItem('hentaiImages') || '[]'); images.unshift({ imageUrl, url: hentaiUrl, language, pages, title }); // Add title to stored data if (images.length > 10) { images.pop(); } localStorage.setItem('hentaiImages', JSON.stringify(images)); localStorage.setItem('currentImageIndex', '0'); updatePreviewImage(imageUrl, language, pages, title); } function getImagesFromLocalStorage() { return JSON.parse(localStorage.getItem('hentaiImages') || '[]'); } function showNextImage() { const images = getImagesFromLocalStorage(); if (images.length === 0) return; let currentIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); currentIndex = (currentIndex - 1 + images.length) % images.length; localStorage.setItem('currentImageIndex', currentIndex.toString()); const currentImage = images[currentIndex]; updatePreviewImage(currentImage.imageUrl, currentImage.language, currentImage.pages, currentImage.title); } function showPreviousImage() { const images = getImagesFromLocalStorage(); if (images.length === 0) return; let currentIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); currentIndex = (currentIndex + 1) % images.length; localStorage.setItem('currentImageIndex', currentIndex.toString()); const currentImage = images[currentIndex]; updatePreviewImage(currentImage.imageUrl, currentImage.language, currentImage.pages, currentImage.title); } function updatePreviewImage(imageUrl, language = '', pages = '', title = '') { const coverPreview = document.getElementById('cover-preview'); const coverPreviewLink = document.getElementById('cover-preview-link'); const notesContainer = document.getElementById('preview-notes'); const isPaused = !window.searchInProgress; if (coverPreview) { coverPreview.src = imageUrl; coverPreview.style.display = 'block'; } // Update the link URL if (coverPreviewLink) { const images = getImagesFromLocalStorage(); const currentIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); if (images[currentIndex] && images[currentIndex].url) { coverPreviewLink.href = images[currentIndex].url; } } if (notesContainer) { notesContainer.innerHTML = ` ${isPaused ? `<div style="margin-bottom: 5px;"><span style="font-weight: bold;">Title:</span> ${title || 'Title Not Available'}</div>` : ''} <div>Language: ${language || 'N/A'}</div> <div>Pages: ${pages || 'N/A'}</div> `; } } // Remove the old click event listener from the image and add it to the link instead (Not necessary may remove later) document.addEventListener('DOMContentLoaded', function() { const coverPreviewLink = document.getElementById('cover-preview-link'); if (coverPreviewLink) { coverPreviewLink.addEventListener('click', function(event) { event.preventDefault(); const currentImageIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); const images = getImagesFromLocalStorage(); if (images[currentImageIndex] && images[currentImageIndex].url) { window.location.href = images[currentImageIndex].url; } }); } }); function togglePause() { window.searchInProgress = !window.searchInProgress; const pauseButtonIcon = document.querySelector('#pause-search i'); pauseButtonIcon.className = window.searchInProgress ? 'fas fa-pause' : 'fas fa-play'; // Update the current image display with the new pause state const images = getImagesFromLocalStorage(); const currentIndex = parseInt(localStorage.getItem('currentImageIndex') || '0', 10); if (images[currentIndex]) { const currentImage = images[currentIndex]; updatePreviewImage(currentImage.imageUrl, currentImage.language, currentImage.pages, currentImage.title); } if (window.searchInProgress) { fetchRandomHentai(); } } // Initialize the current image index localStorage.setItem('currentImageIndex', '0'); //----------------------------**Random Hentai Preferences**---------------------------- //---------------------------**Open In New Tab Button**--------------------------------- // Add this code after the existing findVersionButton code in the same section async function addNewTabButtons() { // Check if the feature is enabled const openInNewTabEnabled = await GM.getValue('openInNewTabEnabled', true); if (!openInNewTabEnabled) return; const openInNewTabType = await GM.getValue('openInNewTabType', 'background'); const baseUrl = 'https://nhentai.net'; const covers = document.querySelectorAll('.cover'); covers.forEach(cover => { // Check if the button doesn't already exist for this cover if (!cover.querySelector('.newTabButton')) { const newTabButton = document.createElement('div'); newTabButton.className = 'newTabButton'; newTabButton.innerHTML = '<i class="fas fa-external-link-alt"></i>'; // Updated to include icon // Add click event listener newTabButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Prevent the click from bubbling up to the cover // Get the href from the cover const mangaUrl = cover.getAttribute('href'); console.log('Opening manga URL:', mangaUrl); // Debugging log if (mangaUrl) { const fullUrl = baseUrl + mangaUrl; // Construct the full URL if (openInNewTabType === 'foreground') { console.log("foreground"); window.open(fullUrl, '_blank'); // Open in new tab and focus on it } else { console.log("background"); GM.openInTab(fullUrl, { active: false }); // Open in new tab without focusing on it } }else { console.error('No URL found for this cover.'); // Error log if no URL } }); cover.appendChild(newTabButton); } }); } // Add observer to handle dynamically loaded content const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.addedNodes.length) { addNewTabButtons(); } }); }); // Start observing the document with the configured parameters observer.observe(document.body, { childList: true, subtree: true }); // Initial call to add buttons to existing covers addNewTabButtons(); //---------------------------**Open In New Tab Button**--------------------------------- //----------------------------**Manga BookMark**--------------------------------- function mangaBookmarking() { GM.addStyle(` @keyframes bookmark-pulse { 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(237, 37, 83, 0.7); } 70% { transform: scale(1.05); box-shadow: 0 0 5px 10px rgba(237, 37, 83, 0); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(237, 37, 83, 0); } } .bookmark-animation { animation: bookmark-pulse 0.6s ease-out; } `); // Get the download button const downloadButton = document.getElementById('download'); if (!downloadButton) { console.log('Download button not found.'); return; } // Check if the manga bookmarking button is enabled in settings async function getMangaBookMarkingButtonEnabled() { return await GM.getValue('mangaBookMarkingButtonEnabled', true); } getMangaBookMarkingButtonEnabled().then(mangaBookMarkingButtonEnabled => { if (!mangaBookMarkingButtonEnabled) return; // Get the current URL const currentUrl = window.location.href; // Check if the current manga is already bookmarked async function getBookmarkedMangas() { try { const bookmarkedMangas = await GM.getValue('bookmarkedMangas', []); return bookmarkedMangas; } catch (error) { console.error('Error checking bookmarks:', error); return []; } } getBookmarkedMangas().then(bookmarkedMangas => { let bookmarkText = 'Bookmark'; let bookmarkClass = 'btn-enabled'; if (bookmarkedMangas.some(manga => manga.url === currentUrl)) { bookmarkText = 'Bookmarked'; bookmarkClass = 'btn-disabled'; } const MangaBookMarkHtml = ` <a class="btn btn-primary ${bookmarkClass} tooltip bookmark" id="bookmark-button"> <i class="fas fa-bookmark"></i> <span>${bookmarkText}</span> <div class="top">Click to save this manga for later<i></i></div> </a> `; // Insert 'Find Similar' button next to the download button $(downloadButton).after(MangaBookMarkHtml); // Add event listener to the bookmark button document.getElementById('bookmark-button').addEventListener('click', async function() { // Get the current URL const currentUrl = window.location.href; // Get the cover image URL const coverImageContainer = document.getElementById('cover'); const coverImage = coverImageContainer.querySelector('img'); const coverImageUrl = coverImage.dataset.src || coverImage.src; try { // Get the bookmarked mangas (asynchronously) const bookmarkedMangas = await GM.getValue('bookmarkedMangas', []); const existingManga = bookmarkedMangas.find(manga => manga.url === currentUrl); if (existingManga) { // If already bookmarked, remove it const index = bookmarkedMangas.indexOf(existingManga); bookmarkedMangas.splice(index, 1); this.querySelector('span').textContent = 'Bookmark'; this.classList.remove('btn-disabled'); this.classList.add('btn-enabled'); } else { // If not bookmarked, add it bookmarkedMangas.push({ url: currentUrl, coverImageUrl: coverImageUrl }); this.querySelector('span').textContent = 'Bookmarked'; this.classList.remove('btn-enabled'); this.classList.add('btn-disabled'); } const isNewlyBookmarked = !existingManga; // True if we just added a bookmark // Save the updated list (asynchronously) await GM.setValue('bookmarkedMangas', bookmarkedMangas); if (isNewlyBookmarked) { // If a new bookmark was added this.classList.add('bookmark-animation'); setTimeout(() => { this.classList.remove('bookmark-animation'); }, 600); // Animation duration 0.6s } } catch (error) { console.error('Error handling bookmarks:', error); // Optionally display an error to the user alert('An error occurred while saving your bookmark.'); } }); }); }); } mangaBookmarking(); //----------------------------**Manga BookMark**--------------------------------- //---------------------------**Month Filter**------------------------------------ //----------------------------**Share Gallery Button**--------------------------------- async function addShareButton() { try { const shareEnabled = await GM.getValue('shareButtonEnabled', true); if (!shareEnabled) return; // Only add on gallery page where the download button exists const downloadButton = document.getElementById('download'); if (!downloadButton) return; // Avoid duplicating the button if (document.getElementById('share-gallery-button')) return; const shareHtml = ` <a class="btn btn-primary btn-enabled tooltip share-gallery" id="share-gallery-button"> <i class="fas fa-share-alt"></i> <span>Share</span> <div class="top">Share to recipient by UUID<i></i></div> </a> `; // Prefer inserting after Bookmark if present, else after Download const $bookmarkBtn = $('#bookmark-button'); if ($bookmarkBtn.length) { $bookmarkBtn.after(shareHtml); } else { $(downloadButton).after(shareHtml); } const shareBtn = document.getElementById('share-gallery-button'); if (!shareBtn) return; shareBtn.addEventListener('click', async function(e) { e.preventDefault(); e.stopPropagation(); const currentUrl = window.location.href; const idMatch = currentUrl.match(/\/g\/(\d+)/); const galleryId = idMatch ? idMatch[1] : null; // Build popup to collect recipient UUID const content = ` <div style="text-align:left"> <div style="font-weight:600;margin-bottom:6px;">Send this gallery to a recipient UUID</div> <input type="text" id="nhp-share-recipient" placeholder="Recipient ID" style="width:100%;padding:8px;border:1px solid #999;border-radius:6px;"> <div style="font-size:12px;color:#666;margin-top:6px;">The recipient must have Receive Shares enabled.</div> </div> `; const btnEl = this; showPopup(content, { buttons: [ { text: 'Send', callback: async () => { let toUUID = (document.getElementById('nhp-share-recipient')?.value || '').trim().toUpperCase(); if (!toUUID) { showPopup('Please enter a recipient ID.', { timeout: 2500 }); return; } // Only accept exactly 5 alphanumeric characters (UUID format used by this script) if (!/^[A-Z0-9]{5}$/.test(toUUID)) { showPopup('Recipient ID must be exactly 5 letters/numbers.', { timeout: 3000 }); return; } // Verify recipient exists in JSONStorage public cloud let recipientExists = false; try { const users = await syncSystem.getAvailableUsers('jsonstorage', syncSystem.publicConfig); recipientExists = Array.isArray(users) && users.some(u => String(u.uuid).toUpperCase() === toUUID); } catch (e) { showPopup('Could not verify recipient in cloud. Please try again.', { timeout: 4000 }); return; } if (!recipientExists) { showPopup('Recipient ID not found in cloud sync. Ask them to enable sync once.', { timeout: 5000 }); return; } // Disable while processing btnEl.classList.remove('btn-enabled'); btnEl.classList.add('btn-disabled'); try { let fromUUID = ''; try { fromUUID = await syncSystem.getUserUUID(); } catch (_) {} const payload = { toUUID, id: galleryId, url: currentUrl, fromUUID }; const res = await fetch('https://nhentai-share.babykoolstar.workers.dev/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(`Send failed (${res.status})`); const data = await res.json(); if (data && data.status === 'ok') { showPopup('Gallery shared successfully!', { timeout: 3000 }); } else { showPopup('Share request sent, but unexpected response.', { timeout: 3000 }); } } catch (err) { showPopup(`Failed to send share: ${err.message}`, { timeout: 4000 }); } finally { btnEl.classList.remove('btn-disabled'); btnEl.classList.add('btn-enabled'); } } }, { text: 'Cancel', callback: () => {} } ], timeout: 0 }); }); } catch (error) { console.error('Error adding Share button:', error); } } // Initialize Share button on page load addShareButton(); //----------------------------**Share Gallery Button**--------------------------------- //----------------------------**Share Inbox Poller**--------------------------------- async function startShareInboxPoller() { try { const receiveEnabled = await GM.getValue('receiveSharesEnabled', true); if (!receiveEnabled) return; const userUUID = await syncSystem.getUserUUID(); if (!userUUID) return; // Poll interval is stored in minutes (migrate from legacy seconds if needed) const legacySec = await GM.getValue('inboxPollIntervalSec', null); const pollIntervalMin = await GM.getValue('inboxPollIntervalMin', legacySec != null ? Math.max(1, Math.round(Number(legacySec) / 60)) : 5); const showPopups = await GM.getValue('receivePopupsEnabled', true); const pollOnce = async () => { try { const messages = await fetchInboxOnce(true); if (Array.isArray(messages) && messages.length) { await appendToInbox(messages); // If settings page is open, refresh the list await renderInboxList(); if (showPopups) { for (const msg of messages) { const galleryUrl = msg?.url || (msg?.id ? `https://nhentai.net/g/${msg.id}/` : ''); const content = ` <div style="text-align:left"> <div style="font-weight:600;margin-bottom:6px;">A gallery was shared with you</div> <div style="font-size:12px;color:#666;margin-bottom:6px;">From UUID: ${msg?.fromUUID || 'Unknown'}</div> </div> `; showPopup(content, { buttons: [ { text: 'Open', callback: () => { if (!galleryUrl) return; const popup = window.open(galleryUrl, '_blank'); if (!popup) window.location.href = galleryUrl; } }, { text: 'Dismiss', callback: () => {} } ], timeout: 0 }); } } } } catch (_) { /* ignore */ } }; // Interval-only polling (no immediate poll on page load) with last-poll gating const intervalMs = Math.max(60000, (parseInt(pollIntervalMin, 10) || 5) * 60 * 1000); setInterval(async () => { try { const lastTs = await GM.getValue('lastInboxPollTS', 0); const now = Date.now(); if (now - lastTs < intervalMs) { return; // Skip if not enough time has passed since last poll } await pollOnce(); await GM.setValue('lastInboxPollTS', Date.now()); } catch (_) {} }, intervalMs); } catch (e) { console.error('Share inbox poller error:', e); } } //----------------------------**Share Inbox Poller**--------------------------------- // Helper: fetch inbox messages once, optionally draining async function fetchInboxOnce(drain = true) { try { const userUUID = await syncSystem.getUserUUID(); if (!userUUID) return []; const url = `https://nhentai-share.babykoolstar.workers.dev/inbox?uuid=${encodeURIComponent(userUUID)}&drain=${drain ? 'true' : 'false'}`; const res = await fetch(url, { method: 'GET' }); if (!res.ok) return []; const messages = await res.json(); return Array.isArray(messages) ? messages : []; } catch (_) { return []; } } // Helper: append messages to local Inbox storage async function appendToInbox(newMessages) { try { const existing = await GM.getValue('inboxMessages', []); const combined = existing.concat(newMessages.map(m => ({ toUUID: m.toUUID, fromUUID: m.fromUUID, id: m.id, url: m.url, ts: m.ts || Date.now() }))); await GM.setValue('inboxMessages', combined); } catch (_) {} } // Render Inbox list in settings async function renderInboxList() { const container = $('#inbox-list'); if (!container.length) return; const msgs = await GM.getValue('inboxMessages', []); if (!Array.isArray(msgs) || msgs.length === 0) { container.html('<div style="font-size:12px;color:#999;">Inbox is empty</div>'); return; } let html = '<ul id="inbox-items" style="list-style:none;padding:0;margin:0">'; msgs.forEach((m, i) => { const time = new Date(m.ts || Date.now()).toLocaleString(); const title = m.id ? `Gallery #${m.id}` : 'Shared item'; const url = m.url || (m.id ? `https://nhentai.net/g/${m.id}/` : ''); html += ` <li class="inbox-item" data-index="${i}" style="padding:8px;border:1px solid #333;border-radius:4px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;"> <div style="text-align:left;"> <div style="font-weight:600;">${title}</div> <div style="font-size:12px;color:#ccc;">From: ${m.fromUUID || 'Unknown'} • ${time}</div> </div> <div> ${url ? '<button type="button" class="inbox-open btn-secondary" style="margin-right:6px;">Open</button>' : ''} <button type="button" class="inbox-delete btn-secondary">Delete</button> </div> </li>`; }); html += '</ul>'; container.html(html); // Attach handlers container.find('.inbox-open').off('click').on('click', async function() { const index = $(this).closest('.inbox-item').data('index'); const msgs = await GM.getValue('inboxMessages', []); const m = msgs[index]; const url = m?.url || (m?.id ? `https://nhentai.net/g/${m.id}/` : ''); if (url) { const popup = window.open(url, '_blank'); if (!popup) window.location.href = url; } }); container.find('.inbox-delete').off('click').on('click', async function() { const index = $(this).closest('.inbox-item').data('index'); const content = ` <div style="text-align:left"> <div style="font-weight:600;margin-bottom:6px;">Delete Message</div> <div style="font-size:12px;color:#666;">Are you sure you want to delete this inbox message?</div> </div> `; showPopup(content, { buttons: [ { text: 'Delete', callback: async () => { let msgs = await GM.getValue('inboxMessages', []); msgs.splice(index, 1); await GM.setValue('inboxMessages', msgs); await renderInboxList(); showPopup('Message deleted.', { timeout: 1500 }); } }, { text: 'Cancel', callback: () => {} } ], timeout: 0 }); }); } async function addMonthFilter() { const monthFilterEnabled = await GM.getValue('monthFilterEnabled', true); if (!monthFilterEnabled) return; const path = window.location.pathname; if (/^\/(search|tag|artist|character|parody)\//.test(path)) { const sortTypes = document.getElementsByClassName("sort-type"); if (sortTypes.length > 1) { let baseUrl = window.location.pathname; // Remove existing popularity filter from the path if present. baseUrl = baseUrl.replace(/\/popular(-\w+)?$/, ''); const urlParams = new URLSearchParams(window.location.search); urlParams.delete('sort'); // Remove any sort parameter from the query string const remainingParams = urlParams.toString(); if (remainingParams) { baseUrl += '?' + remainingParams; } const monthFilterHtml = ` <span class="sort-name">Popular:</span> <a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}sort=popular-today">today</a> <a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}sort=popular-week">week</a> <a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}sort=popular-month">month</a> <a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}sort=popular">all time</a> `; sortTypes[1].innerHTML = monthFilterHtml; } } } addMonthFilter(); //--------------------------*Month Filter**---------------------------------------- //--------------------------- **Replace Related Manga with Bookmarks** --------------------------- // Function to get manga details (cover image and title) async function getMangaDetails(mangaId) { try { // First check if we have details cached const cachedDetails = await GM.getValue(`manga_details_${mangaId}`, null); if (cachedDetails) { return cachedDetails; } // If not cached, fetch it from the page const response = await fetch(`https://nhentai.net/g/${mangaId}/`); if (!response.ok) { console.error(`Failed to fetch manga page for ${mangaId}: ${response.status}`); return { coverUrl: null, title: null }; } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Get the cover image const coverImg = doc.querySelector("#cover > a > img"); const coverUrl = coverImg ? (coverImg.getAttribute('data-src') || coverImg.getAttribute('src')) : null; // Get the manga title let title = null; // Try to get the title from the span.before element const titleSpan = doc.querySelector("#info > h1 > a > u > span.before"); if (titleSpan) { title = titleSpan.textContent.trim(); } // If not found, try the main h1 title if (!title) { const mainTitle = doc.querySelector("#info > h1"); if (mainTitle) { title = mainTitle.textContent.trim(); } } // Extract all tags from the page const allTags = Array.from(doc.querySelectorAll('#tags span.name')).map(span => span.textContent.trim().toLowerCase() ); // Determine language from tags let language = null; if (allTags.includes('english')) { language = 'english'; } else if (allTags.includes('japanese')) { language = 'japanese'; } else if (allTags.includes('chinese')) { language = 'chinese'; } // Cache the details const details = { coverUrl, title, language }; await GM.setValue(`manga_details_${mangaId}`, details); return details; } catch (error) { console.error(`Error getting manga details for ${mangaId}:`, error); return { coverUrl: null, title: null }; } } // Function to get cover image URL for a manga (for backward compatibility) async function getMangaCoverImage(mangaId) { const details = await getMangaDetails(mangaId); return details.coverUrl; } // Language flag URLs const LANGUAGE_FLAGS = { english: "https://i.imgur.com/vSnHmmi.gif", japanese: "https://i.imgur.com/GlArpuS.gif", chinese: "https://i.imgur.com/7B55DYm.gif" }; // Function to replace the related manga section with bookmarked content async function replaceRelatedWithBookmarks() { // Check if the feature is enabled const replaceRelatedWithBookmarks = await GM.getValue('replaceRelatedWithBookmarks', true); if (!replaceRelatedWithBookmarks) return; // Check if flip button is enabled const enableRelatedFlipButton = await GM.getValue('enableRelatedFlipButton', true); // Check if we're on a manga page and if the related container exists const relatedContainer = document.querySelector("#related-container"); if (!relatedContainer || !window.location.pathname.includes('/g/')) return; // Store original content for flipping back const originalContent = relatedContainer.innerHTML; // State management for flip functionality (only if flip button is enabled) let isShowingBookmarks = true; // Default to showing bookmarks when enabled let bookmarkedContent = null; // Add a loading indicator relatedContainer.innerHTML = ` <h2>Finding Related Manga from Your Bookmarks...</h2> <div class="container" style="text-align: center; padding: 20px;"> <div class="loading-spinner" style="display: inline-block; width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #555; border-radius: 50%; animation: spin 1s linear infinite;"></div> </div> <style> @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style> `; // Get the current manga ID const currentMangaId = window.location.pathname.match(/\/g\/(\d+)/)?.[1]; if (!currentMangaId) return; // Get the current manga's tags const tagsContainer = document.querySelector("#tags"); if (!tagsContainer) return; // Extract all tags from the current manga const tagElements = tagsContainer.querySelectorAll('.tag'); const currentTags = Array.from(tagElements).map(tag => { return tag.querySelector('.name')?.textContent.trim().toLowerCase() || ''; }).filter(tag => tag !== ''); // Learn artist names from the Artists group on the page try { const tagGroups = Array.from(tagsContainer.querySelectorAll('.tag-container')); const artistGroup = tagGroups.find(c => { const fn = c.querySelector('.field-name'); return fn && /artists/i.test(fn.textContent); }); if (artistGroup) { const artistsNow = Array.from(artistGroup.querySelectorAll('a.tag .name')).map(el => el.textContent.trim()); if (artistsNow.length) { addKnownArtists(artistsNow); } } } catch (_) {} console.log('Current manga tags:', currentTags); await addKnownMultiwordTags(currentTags); // Get all bookmarks const bookmarks = await getBookmarksFromStorage(); if (!bookmarks || bookmarks.length === 0) { console.log('No bookmarks found'); // Restore original content instead of returning to prevent infinite loading relatedContainer.innerHTML = originalContent; return; } console.log(`Found ${bookmarks.length} bookmarks`); // Filter out the current manga from bookmarks const filteredBookmarks = bookmarks.filter(bookmark => bookmark.id !== currentMangaId); // Function to score bookmarks based on tag similarity async function scoreBookmark(bookmark) { try { // Get manga info with tags - only use cached data const mangaInfo = await GM.getValue(`manga_${bookmark.id}`, null); // Try to get additional tags from the URL-based cache const mangaUrl = `https://nhentai.net/g/${bookmark.id}/`; const additionalTags = await GM.getValue(`tags_${mangaUrl}`, []); // If no cached info or tags from either source, skip this bookmark if ((!mangaInfo || !mangaInfo.tags || mangaInfo.tags.length === 0) && additionalTags.length === 0) { return { bookmark, score: 0, tags: [], tagIds: [] }; } // Initialize bookmarkTags array let bookmarkTags = []; // Add tags from mangaInfo if available if (mangaInfo && mangaInfo.tags && mangaInfo.tags.length > 0) { bookmarkTags = mangaInfo.tags.map(tag => tag.replace(/\d+K?$/, '').trim().toLowerCase() ); } // Add tags from URL-based cache if available if (additionalTags.length > 0) { // Clean up additional tags and add them to bookmarkTags const cleanedAdditionalTags = additionalTags.map(tag => typeof tag === 'string' ? tag.replace(/\d+K?$/, '').trim().toLowerCase() : '' ).filter(tag => tag !== ''); // Merge tags, avoiding duplicates bookmarkTags = [...new Set([...bookmarkTags, ...cleanedAdditionalTags])]; } // Get tag IDs if available const tagIds = mangaInfo?.tagIds || []; // Calculate score based on matching tags let score = 0; const matchingTags = []; for (const tag of currentTags) { if (bookmarkTags.includes(tag)) { score++; matchingTags.push(tag); } } // Determine language from tags let language = null; if (bookmarkTags.includes('english')) { language = 'english'; } else if (bookmarkTags.includes('japanese')) { language = 'japanese'; } else if (bookmarkTags.includes('chinese')) { language = 'chinese'; } return { bookmark, score, tags: bookmarkTags, matchingTags, tagIds, title: mangaInfo?.title || `Manga ${bookmark.id}`, thumbnail: mangaInfo?.thumbnail || null, language }; } catch (error) { console.error(`Error scoring bookmark ${bookmark.id}:`, error); return { bookmark, score: 0, tags: [], tagIds: [] }; } } // Process all bookmarks since we're only using cached data console.log(`Processing ${filteredBookmarks.length} bookmarks (using cached data only)`); // Score bookmarks in batches to avoid freezing the browser const BATCH_SIZE = 50; // Larger batch size since we're only using cached data const scoredBookmarks = []; for (let i = 0; i < filteredBookmarks.length; i += BATCH_SIZE) { const batch = filteredBookmarks.slice(i, i + BATCH_SIZE); console.log(`Processing batch ${Math.floor(i/BATCH_SIZE) + 1} of ${Math.ceil(filteredBookmarks.length/BATCH_SIZE)}`); const batchPromises = batch.map(scoreBookmark); const batchResults = await Promise.all(batchPromises); scoredBookmarks.push(...batchResults); // Small delay to keep the UI responsive if (i + BATCH_SIZE < filteredBookmarks.length) { await new Promise(resolve => setTimeout(resolve, 10)); } } // Sort by score (highest first) scoredBookmarks.sort((a, b) => b.score - a.score); // Filter out bookmarks with no matching tags const bookmarksWithMatches = scoredBookmarks.filter(item => item.score > 0); console.log(`Found ${bookmarksWithMatches.length} bookmarks with matching tags`); // If no related bookmarks found with matching tags, restore the original content if (bookmarksWithMatches.length === 0) { console.log('No related bookmarks found with matching tags'); relatedContainer.innerHTML = originalContent; // Update the header text after restoring original content const originalHeader = relatedContainer.querySelector('h2'); if (originalHeader) { originalHeader.textContent = 'More Like This (There were no Bookmarks that match this manga)'; } return; } // Take top 5 or fewer if less available const topBookmarks = bookmarksWithMatches.slice(0, 5); console.log('Top related bookmarks:', topBookmarks); // Pre-fetch titles and thumbnails for top bookmarks await Promise.all(topBookmarks.map(async item => { // Get manga details for title and cover const details = await getMangaDetails(item.bookmark.id); // Update title if needed if ((!item.title || item.title === `Manga ${item.bookmark.id}`) && details.title) { item.title = details.title; } // Update thumbnail if needed if (!item.thumbnail && details.coverUrl) { item.thumbnail = details.coverUrl; } })); // Clear the related container relatedContainer.innerHTML = ''; // Create a header container for the section with flip button const headerContainer = document.createElement('div'); headerContainer.style.display = 'flex'; headerContainer.style.alignItems = 'center'; headerContainer.style.justifyContent = 'center'; headerContainer.style.gap = '10px'; headerContainer.style.position = 'relative'; const header = document.createElement('h2'); header.textContent = 'Related Manga from Your Bookmarks'; header.style.margin = '0'; header.style.textAlign = 'center'; header.style.flex = '1'; headerContainer.appendChild(header); // Only create flip button if the setting is enabled let flipButton = null; if (enableRelatedFlipButton) { // Create flip button flipButton = document.createElement('button'); flipButton.textContent = 'Flip'; flipButton.style.cssText = ` background: #ed2553; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-weight: bold; position: absolute; right: 0; top: 50%; transform: translateY(-50%); `; // Add hover effect flipButton.addEventListener('mouseenter', () => { flipButton.style.background = '#d91e47'; }); flipButton.addEventListener('mouseleave', () => { flipButton.style.background = '#ed2553'; }); headerContainer.appendChild(flipButton); } relatedContainer.appendChild(headerContainer); // Create a container for the galleries const galleryContainer = document.createElement('div'); galleryContainer.className = 'container'; relatedContainer.appendChild(galleryContainer); // Store the bookmarked content for later use const storeBookmarkedContent = () => { bookmarkedContent = relatedContainer.innerHTML; }; // Function to toggle between bookmarked and original content const toggleContent = () => { if (isShowingBookmarks) { // Switch to original content relatedContainer.innerHTML = originalContent; isShowingBookmarks = false; // Force load thumbnails in original content with comprehensive loading setTimeout(() => { const images = relatedContainer.querySelectorAll('img'); images.forEach((img, index) => { // Store original attributes const dataSrc = img.dataset.src || img.getAttribute('data-src'); const originalSrc = img.src; // Function to attempt loading the image const loadImage = (src) => { return new Promise((resolve, reject) => { const testImg = new Image(); testImg.onload = () => { img.src = src; img.style.opacity = '1'; resolve(src); }; testImg.onerror = () => reject(src); testImg.src = src; }); }; // Try multiple loading strategies const tryLoadImage = async () => { const sources = []; // Add data-src if available if (dataSrc && dataSrc !== originalSrc) { sources.push(dataSrc); } // Add original src if available and different if (originalSrc && !originalSrc.includes('placeholder') && !originalSrc.includes('loading')) { sources.push(originalSrc); } // Try each source for (const src of sources) { try { await loadImage(src); console.log(`Successfully loaded image ${index + 1}:`, src); return; } catch (error) { console.log(`Failed to load image ${index + 1} from:`, src); } } // If all sources fail, try to reconstruct the URL const galleryLink = img.closest('a'); if (galleryLink && galleryLink.href) { const mangaId = galleryLink.href.match(/\/g\/(\d+)/)?.[1]; if (mangaId) { const reconstructedUrl = `https://t.nhentai.net/galleries/${mangaId}/thumb.jpg`; try { await loadImage(reconstructedUrl); console.log(`Successfully loaded reconstructed image ${index + 1}:`, reconstructedUrl); } catch (error) { console.log(`Failed to load reconstructed image ${index + 1}:`, reconstructedUrl); } } } }; // Start loading process tryLoadImage(); // Remove lazy loading classes that might prevent loading img.classList.remove('lazyload', 'lazyloading'); img.classList.add('lazyloaded'); }); // Trigger any lazy loading libraries that might be present if (window.lazyLoadInstance) { window.lazyLoadInstance.update(); } // Trigger intersection observer if present if (window.IntersectionObserver) { const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src && !img.src) { img.src = img.dataset.src; } } }); }); images.forEach(img => observer.observe(img)); // Disconnect after a short time setTimeout(() => observer.disconnect(), 2000); } }, 100); // Add a flip button to the original content to switch back (only if flip button is enabled) const originalHeader = relatedContainer.querySelector('h2'); if (originalHeader && enableRelatedFlipButton) { // Create container for original header with flip button const originalHeaderContainer = document.createElement('div'); originalHeaderContainer.style.display = 'flex'; originalHeaderContainer.style.alignItems = 'center'; originalHeaderContainer.style.justifyContent = 'center'; originalHeaderContainer.style.gap = '10px'; originalHeaderContainer.style.position = 'relative'; // Style the original header originalHeader.style.margin = '0'; originalHeader.style.textAlign = 'center'; originalHeader.style.flex = '1'; // Create flip button for original content const originalFlipButton = document.createElement('button'); originalFlipButton.textContent = 'Flip'; originalFlipButton.style.cssText = ` background: #ed2553; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-weight: bold; position: absolute; right: 0; top: 50%; transform: translateY(-50%); `; // Add hover effects originalFlipButton.addEventListener('mouseenter', () => { originalFlipButton.style.background = '#d91e47'; }); originalFlipButton.addEventListener('mouseleave', () => { originalFlipButton.style.background = '#ed2553'; }); // Add click event to flip back originalFlipButton.addEventListener('click', toggleContent); // Replace the original header with the container originalHeader.parentNode.insertBefore(originalHeaderContainer, originalHeader); originalHeaderContainer.appendChild(originalHeader); originalHeaderContainer.appendChild(originalFlipButton); } } else { // Switch back to bookmarked content if (bookmarkedContent) { relatedContainer.innerHTML = bookmarkedContent; // Re-attach event listener to the new flip button const newFlipButton = relatedContainer.querySelector('button'); if (newFlipButton) { newFlipButton.addEventListener('click', toggleContent); // Re-add hover effects newFlipButton.addEventListener('mouseenter', () => { newFlipButton.style.background = '#d91e47'; }); newFlipButton.addEventListener('mouseleave', () => { newFlipButton.style.background = '#ed2553'; }); } isShowingBookmarks = true; } } }; // Add click event to flip button (only if it exists) if (flipButton) { flipButton.addEventListener('click', toggleContent); } // Add each bookmark to the container for (const item of topBookmarks) { const { bookmark, score, matchingTags, tagIds, title, thumbnail, language } = item; if (score === 0) continue; // Skip bookmarks with no matching tags try { // Create gallery HTML directly using the format from nhentai const tagIdsString = tagIds && tagIds.length > 0 ? tagIds.join(' ') : ''; // Calculate aspect ratio for padding (default to 141.2% if not available) const aspectRatio = 141.2; // Get thumbnail URL - use the cover image if available let thumbUrl = thumbnail || null; // If no thumbnail, try to get it from manga_info if (!thumbUrl) { const mangaInfo = await GM.getValue(`manga_info_${bookmark.id}`, null); if (mangaInfo && mangaInfo.thumbnail) { thumbUrl = mangaInfo.thumbnail; } } // If still no thumbnail, try to get the cover image if (!thumbUrl) { thumbUrl = await getMangaCoverImage(bookmark.id); } // If still no thumbnail, use placeholder if (!thumbUrl) { thumbUrl = 'https://t.nhentai.net/galleries/0/thumb.jpg'; } // Format the title with manga name if available let displayTitle = title || `Manga ${bookmark.id}`; // Create gallery HTML with centered title (no inline language flag) const galleryHTML = ` <div class="gallery" data-tags="${tagIdsString}" data-detected-language="${language || ''}"> <a href="/g/${bookmark.id}/" class="cover" style="padding:0 0 ${aspectRatio}% 0"> <img class="lazyload" width="250" height="353" data-src="${thumbUrl}" src="${thumbUrl}"> <noscript><img src="${thumbUrl}" width="250" height="353" /></noscript> <div class="caption" style="text-align: center;">${displayTitle}</div> </a> </div> `; // Add to container galleryContainer.insertAdjacentHTML('beforeend', galleryHTML); // Get the newly added gallery element const lastGallery = galleryContainer.lastElementChild; // Add matching tags info if available if (matchingTags && matchingTags.length > 0) { const caption = lastGallery.querySelector('.caption'); const tagsInfo = document.createElement('div'); tagsInfo.className = 'matching-tags'; tagsInfo.textContent = `Matching tags: ${matchingTags.join(', ')}`; tagsInfo.style.fontSize = '12px'; tagsInfo.style.color = '#888'; tagsInfo.style.marginTop = '5px'; tagsInfo.style.textAlign = 'center'; caption.appendChild(tagsInfo); } // Add language flag using the overlay system if language is available if (language && lastGallery) { // Check if we have access to the language flag system if (typeof window.nhentaiPlus !== 'undefined' && window.nhentaiPlus.systems && window.nhentaiPlus.systems.languageDetection) { window.nhentaiPlus.systems.languageDetection.addLanguageFlag(lastGallery, language); } } } catch (error) { console.error(`Error creating gallery item for bookmark ${bookmark.id}:`, error); } } // Store the bookmarked content after all galleries are added storeBookmarkedContent(); } // Call the function when the page is loaded $(document).ready(function() { setTimeout(replaceRelatedWithBookmarks, 1000); // Delay to ensure page is fully loaded }); //---------------------------**BookMark-Random-Button**----------------------------- async function appendButton() { const enableRandomButton = await GM.getValue('enableRandomButton', true); if (!enableRandomButton) return; // Check if we're on the bookmarks page if (window.location.pathname.includes('/bookmarks')) { // Pre-fetch the bookmarks outside the observer const bookmarks = await getBookmarksFromStorage(); // Create a function to check for the element and append the button function checkAndAppendButton() { const targets = document.querySelectorAll(".bookmarks-title"); if (targets.length > 0) { targets.forEach(target => { // Check if button already exists to avoid duplicates if (target.nextElementSibling && target.nextElementSibling.classList.contains('random-button')) { return; } // Append the button const button = $('<button class="random-button"><i class="fas fa-random"></i> Random</button>'); $(target).after(button); $(target).css('display', 'inline-block'); button.css({ 'display': 'inline-block', 'margin-left': '10px', 'position': 'relative', 'top': '-3px' }); button.on('click', async () => { if (bookmarks.length > 0) { const randomIndex = Math.floor(Math.random() * bookmarks.length); const randomBookmark = bookmarks[randomIndex]; const link = `https://nhentai.net/g/${randomBookmark.id}/`; // Store bookmark info in localStorage for the next page localStorage.setItem('randomMangaSource', JSON.stringify({ source: randomBookmark.source, id: randomBookmark.id })); // Get the openInNewTabType value from storage const openInNewTabType = await GM.getValue('openInNewTabType', 'new-tab'); const enableRandomButton = await GM.getValue('enableRandomButton', true); const randomOpenType = await GM.getValue('randomOpenType', 'new-tab'); // Determine how to open the link based on the openInNewTabType value if (enableRandomButton && randomOpenType === 'new-tab') { // Open the link in a new tab window.open(link, '_blank'); } else if (enableRandomButton && randomOpenType === 'current-tab') { // Open the link in the current tab window.location.href = link; } else if (openInNewTabType === 'new-tab') { // Open the link in a new tab window.open(link, '_blank'); } else if (openInNewTabType === 'current-tab') { // Open the link in the current tab window.location.href = link; } } else { showPopup("No bookmarks found.", { timeout: 3000 }); } }); }); // Clear the interval since we've found the elements clearInterval(intervalId); } } // Set an interval to check for the element every second const intervalId = setInterval(checkAndAppendButton, 1); } else { // Check if we're on a manga page and show the popup checkRandomMangaSource(); } } function checkRandomMangaSource() { const randomMangaSource = localStorage.getItem('randomMangaSource'); if (randomMangaSource) { try { const { source } = JSON.parse(randomMangaSource); let popupText; if (source.startsWith('bookmark_manga_ids_')) { const link = source.replace('bookmark_manga_ids_', ''); const maxLength = 40; // maximum length of the link to display const displayedLink = link.length > maxLength ? link.substring(0, maxLength) + '...' : link; popupText = `Random manga from <a href="${link}" target="_blank" style="word-wrap: break-word; width: 200px; display: inline-block; vertical-align: top;">${displayedLink}</a>`; } else { popupText = `Random manga from ${source}`; } // Create popup with options to random again or continue browsing showPopup(popupText, { autoClose: false, width: 250, // adjust the width to fit the link buttons: [ { text: "<i class='fas fa-check'></i> Continue", callback: () => { // Just close the popup } }, { text: "<i class='fas fa-random'></i> Again", callback: async () => { // Get bookmarks and find a new random one directly const bookmarks = await getBookmarksFromStorage(); if (bookmarks.length > 0) { const randomIndex = Math.floor(Math.random() * bookmarks.length); const randomBookmark = bookmarks[randomIndex]; const link = `https://nhentai.net/g/${randomBookmark.id}`; // Store bookmark info in localStorage for the next page localStorage.setItem('randomMangaSource', JSON.stringify({ source: randomBookmark.source, id: randomBookmark.id })); // Navigate to the new manga page window.location.href = link; } else { showPopup("No bookmarks found.", { timeout: 3000 }); } } } ] }); // Clear the localStorage item localStorage.removeItem('randomMangaSource'); } catch (error) { console.error('Error parsing random manga source', error); } } } appendButton(); async function getBookmarksFromStorage() { const bookmarks = []; const addedIds = new Set(); // Check for bookmarks in the first format (simple array of IDs) const allKeys = await GM.listValues(); for (const key of allKeys) { if (key.startsWith("bookmark_manga_ids_")) { const ids = await GM.getValue(key); if (Array.isArray(ids)) { // Add each ID as a bookmark object ids.forEach(id => { if (!addedIds.has(id)) { bookmarks.push({ id: id, url: `https://nhentai.net/g/${id}/`, source: key }); addedIds.add(id); } }); } } } // Check for bookmarks in the second format (array of objects) const bookmarkedMangas = await GM.getValue("bookmarkedMangas"); if (Array.isArray(bookmarkedMangas)) { bookmarkedMangas.forEach(manga => { // Extract ID from URL if it exists if (manga.url) { const match = manga.url.match(/\/g\/(\d+)/); if (match && match[1]) { const id = match[1]; // Check if this ID is already in our bookmarks array if (!addedIds.has(id)) { bookmarks.push({ id: id, url: manga.url, cover: manga.cover || null, title: manga.title || null, source: "bookmarkedMangas" }); addedIds.add(id); } } } }); } return bookmarks; } function getMangaLink(mangaID) { return `https://nhentai.net/g/${mangaID}`; } //---------------------------**BookMark-Random-Button**----------------------------- //--------------------------**Offline Favoriting**---------------------------------------------- // Main function to initialize the script async function init() { const offlineFavoritingEnabled = await GM.getValue('offlineFavoritingEnabled', true); if (!offlineFavoritingEnabled) return; console.log("NHentai Favorite Manager initialized"); // Check if user is logged in const isLoggedIn = !document.querySelector('.menu-sign-in'); console.log("User logged in status:", isLoggedIn); // Process stored favorites if user is logged in, regardless of current page if (isLoggedIn) { const toFavorite = await GM.getValue('toFavorite', []); if (Array.isArray(toFavorite) && toFavorite.length > 0) { console.log("Found stored favorites to process:", toFavorite); await processFavorites(toFavorite); } const toUnfavorite = await GM.getValue('toUnfavorite', []); if (Array.isArray(toUnfavorite) && toUnfavorite.length > 0) { console.log("Found stored unfavorites to process:", toUnfavorite); await processUnfavorites(toUnfavorite); } } // Only proceed with manga-specific features if we're on a manga page if (window.location.pathname.includes('/g/')) { await handleMangaPage(isLoggedIn); } } // Handle manga page-specific functionality async function handleMangaPage(isLoggedIn) { // Get the manga ID from the URL const mangaId = getMangaIdFromUrl(); console.log("Current manga ID:", mangaId); if (!mangaId) { console.log("Could not find manga ID, exiting manga-specific handling"); return; } // Get favorite button const favoriteBtn = document.querySelector("#info > div")?.firstElementChild; // Set up interval to log favorite button every 5 seconds /*setInterval(() => { console.log("Favorite button:", favoriteBtn); }, 5000);*/ if (!favoriteBtn) { console.log("Could not find favorite button, exiting manga-specific handling"); return; } function isButtonFavorited(button) { if (!button) return false; if (button.classList && button.classList.contains('favorited')) return true; const textEl = button.querySelector('span.text'); const text = String((textEl ? textEl.textContent : button.textContent) || '').trim(); return text.toLowerCase().includes('unfavorite'); } async function updateOfflineFavoritesFromButtonState(button) { const shouldBeFavorited = isButtonFavorited(button); let currentOfflineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(currentOfflineFavorites)) currentOfflineFavorites = []; const has = currentOfflineFavorites.includes(mangaId); if (shouldBeFavorited && !has) { currentOfflineFavorites.push(mangaId); await GM.setValue('offlineFavorites', currentOfflineFavorites); } else if (!shouldBeFavorited && has) { currentOfflineFavorites = currentOfflineFavorites.filter(id => id !== mangaId); await GM.setValue('offlineFavorites', currentOfflineFavorites); } } // Get stored favorites (persisted) + pending favorites (not yet synced) let offlineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(offlineFavorites)) { offlineFavorites = []; await GM.setValue('offlineFavorites', offlineFavorites); } let toFavorite = await GM.getValue('toFavorite', []); if (!Array.isArray(toFavorite)) { toFavorite = []; await GM.setValue('toFavorite', toFavorite); } let toUnfavorite = await GM.getValue('toUnfavorite', []); if (!Array.isArray(toUnfavorite)) { toUnfavorite = []; await GM.setValue('toUnfavorite', toUnfavorite); } console.log("Stored favorites:", offlineFavorites); console.log("Pending favorites:", toFavorite); console.log("Pending unfavorites:", toUnfavorite); // Is this manga in our favorites? const isFavorited = offlineFavorites.includes(mangaId) || toFavorite.includes(mangaId); console.log("Current manga in stored favorites:", isFavorited); // Enable button if disabled if (favoriteBtn.classList.contains('btn-disabled') && !isLoggedIn) { favoriteBtn.classList.remove('btn-disabled'); console.log("Favorite button enabled"); } // Update button state if it's in our favorites if (isFavorited && !isLoggedIn) { updateButtonToFavorited(favoriteBtn); } if (isLoggedIn) { await updateOfflineFavoritesFromButtonState(favoriteBtn); } // Add click event to favorite button favoriteBtn.addEventListener('click', async function(e) { console.log("Favorite button clicked"); if (isLoggedIn) { setTimeout(() => { updateOfflineFavoritesFromButtonState(favoriteBtn); }, 350); return; } e.preventDefault(); e.stopPropagation(); // Get the CURRENT list of favorites (not the one from page load) // This ensures we have the most up-to-date list let currentFavorites = await GM.getValue('toFavorite', []); if (!Array.isArray(currentFavorites)) { currentFavorites = []; } let currentOfflineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(currentOfflineFavorites)) currentOfflineFavorites = []; let currentPendingUnfavorites = await GM.getValue('toUnfavorite', []); if (!Array.isArray(currentPendingUnfavorites)) currentPendingUnfavorites = []; // Check if this manga is CURRENTLY in favorites const currentlyFavorited = currentFavorites.includes(mangaId) || currentOfflineFavorites.includes(mangaId); console.log("Manga currently in favorites:", currentlyFavorited); if (!isLoggedIn) { if (currentlyFavorited) { const wasPendingFavorite = currentFavorites.includes(mangaId); const pendingIndex = currentFavorites.indexOf(mangaId); if (pendingIndex !== -1) currentFavorites.splice(pendingIndex, 1); currentOfflineFavorites = currentOfflineFavorites.filter(id => id !== mangaId); if (!wasPendingFavorite && !currentPendingUnfavorites.includes(mangaId)) { currentPendingUnfavorites.push(mangaId); } updateButtonToUnfavorited(favoriteBtn); console.log("Removed manga from stored favorites:", mangaId); } else { if (!currentFavorites.includes(mangaId)) currentFavorites.push(mangaId); if (!currentOfflineFavorites.includes(mangaId)) currentOfflineFavorites.push(mangaId); currentPendingUnfavorites = currentPendingUnfavorites.filter(id => id !== mangaId); updateButtonToFavorited(favoriteBtn); console.log("Added manga to stored favorites:", mangaId); } await GM.setValue('toFavorite', currentFavorites); await GM.setValue('toUnfavorite', currentPendingUnfavorites); await GM.setValue('offlineFavorites', currentOfflineFavorites); console.log("Updated pending favorites:", currentFavorites); console.log("Updated pending unfavorites:", currentPendingUnfavorites); console.log("Updated stored favorites:", currentOfflineFavorites); } }); } // Helper function to get manga ID from URL function getMangaIdFromUrl() { const urlPath = window.location.pathname; const match = urlPath.match(/\/g\/(\d+)/); return match ? match[1] : null; } // Extract CSRF token from page function getCsrfToken() { // Try to get from app initialization const scriptText = document.body.innerHTML; const tokenMatch = scriptText.match(/csrf_token:\s*"([^"]+)"/); if (tokenMatch && tokenMatch[1]) { console.log("Found CSRF token from script:", tokenMatch[1]); return tokenMatch[1]; } // Try alternative method - look for form inputs const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]'); if (csrfInput) { console.log("Found CSRF token from input:", csrfInput.value); return csrfInput.value; } console.log("Could not find CSRF token"); return null; } // Nhentai Plus+.user.js (4405-4427) function updateButtonToFavorited(button) { button.classList.add('favorited'); const icon = button.querySelector('i'); const text = button.querySelector('span'); if (icon) icon.className = 'far fa-heart'; // Solid (filled) heart if (text) { const countSpan = text.querySelector('span.nobold'); text.innerText = 'Unfavorite '; if (countSpan) { text.appendChild(countSpan); } } console.log("Button updated to favorited state"); } function updateButtonToUnfavorited(button) { button.classList.remove('favorited'); const icon = button.querySelector('i'); const text = button.querySelector('span'); if (icon) icon.className = 'fas fa-heart'; // Regular (outline) heart if (text) { const countSpan = text.querySelector('span.nobold'); text.innerText = 'Favorite '; if (countSpan) { text.appendChild(countSpan); } } console.log("Button updated to unfavorited state"); } // Modified sendFavoriteRequest function with improved CSRF token handling async function sendFavoriteRequest(mangaId) { const isIOSDevice = await GM.getValue('isIOSDevice', false); if (isIOSDevice) { // For iOS, we'll use a more compatible method return new Promise((resolve, reject) => { console.log("Using iOS-compatible favoriting method for manga:", mangaId); // Get CSRF token using improved method const csrfToken = getCsrfToken(); if (!csrfToken) { console.error("Could not find CSRF token for request"); reject(new Error("Missing CSRF token")); return; } // Create a temporary form to submit const form = document.createElement('form'); form.method = 'POST'; form.action = `https://nhentai.net/api/gallery/${mangaId}/favorite`; form.style.display = 'none'; // Add CSRF token to form const csrfInput = document.createElement('input'); csrfInput.type = 'hidden'; csrfInput.name = 'csrf_token'; csrfInput.value = csrfToken; form.appendChild(csrfInput); // Add a hidden iframe to target the form const iframe = document.createElement('iframe'); iframe.name = 'favorite_frame'; iframe.style.display = 'none'; document.body.appendChild(iframe); // Set up form target and add to document form.target = 'favorite_frame'; document.body.appendChild(form); // Set up response handling let timeoutId; iframe.onload = () => { clearTimeout(timeoutId); try { // Check if favoriting was successful if (iframe.contentDocument.body.textContent.includes('success')) { console.log("Successfully favorited manga:", mangaId); resolve({ status: 200 }); } else { console.error("Failed to favorite manga:", mangaId); reject(new Error("Failed to favorite manga")); } } catch (e) { // If we can't access iframe content due to CORS, assume success console.log("Could not access iframe content, assuming success"); resolve({ status: 200 }); } // Clean up setTimeout(() => { document.body.removeChild(form); document.body.removeChild(iframe); }, 100); }; // Set timeout in case of no response timeoutId = setTimeout(() => { console.error("Favorite request timed out for manga:", mangaId); document.body.removeChild(form); document.body.removeChild(iframe); reject(new Error("Request timed out")); }, 10000); // Submit the form form.submit(); }); }else{ return new Promise((resolve, reject) => { console.log("Sending favorite request for manga:", mangaId); // Get CSRF token - trying multiple methods let csrfToken = getCsrfToken(); if (!csrfToken) { console.error("Could not find CSRF token for request"); reject(new Error("Missing CSRF token")); return; } // Use fetch API instead of GM.xmlHttpRequest for iOS compatibility // Note: This requires Tampermonkey to grant fetch permissions fetch(`https://nhentai.net/api/gallery/${mangaId}/favorite`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRFToken": csrfToken, "Referer": "https://nhentai.net/g/" + mangaId + "/", "User-Agent": navigator.userAgent }, body: `csrf_token=${encodeURIComponent(csrfToken)}`, credentials: "include", // Important for sending cookies properly mode: "cors" }) .then(response => { console.log("Favorite request response for manga " + mangaId + ":", response.status); if (response.status === 200) { resolve(response); } else { console.error("Favorite request failed for manga " + mangaId + ":", response.status); reject(new Error(`Request failed with status ${response.status}`)); } }) .catch(error => { console.error("Favorite request error for manga " + mangaId + ":", error); reject(error); }); }); } } async function sendUnfavoriteRequest(mangaId) { return new Promise((resolve, reject) => { console.log("Sending unfavorite request for manga:", mangaId); const csrfToken = getCsrfToken(); if (!csrfToken) { console.error("Could not find CSRF token for request"); reject(new Error("Missing CSRF token")); return; } fetch(`https://nhentai.net/api/gallery/${mangaId}/unfavorite`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRFToken": csrfToken, "Referer": "https://nhentai.net/g/" + mangaId + "/", "User-Agent": navigator.userAgent }, body: `csrf_token=${encodeURIComponent(csrfToken)}`, credentials: "include", mode: "cors" }) .then(response => { console.log("Unfavorite request response for manga " + mangaId + ":", response.status); if (response.status === 200) { resolve(response); } else { console.error("Unfavorite request failed for manga " + mangaId + ":", response.status); reject(new Error(`Request failed with status ${response.status}`)); } }) .catch(error => { console.error("Unfavorite request error for manga " + mangaId + ":", error); reject(error); }); }); } // Improved CSRF token extraction function function getCsrfToken() { // Try to get from script tag with the most up-to-date token const scriptTags = document.querySelectorAll('script:not([src])'); for (const script of scriptTags) { const tokenMatch = script.textContent.match(/csrf_token:\s*"([^"]+)"/); if (tokenMatch && tokenMatch[1]) { console.log("Found CSRF token from inline script:", tokenMatch[1]); return tokenMatch[1]; } } // Try to get from window._n_app object which should have the most recent token if (window._n_app && window._n_app.csrf_token) { console.log("Found CSRF token from window._n_app:", window._n_app.csrf_token); return window._n_app.csrf_token; } // Try getting from page HTML (your original method) const scriptText = document.body.innerHTML; const tokenMatch = scriptText.match(/csrf_token:\s*"([^"]+)"/); if (tokenMatch && tokenMatch[1]) { console.log("Found CSRF token from page HTML:", tokenMatch[1]); return tokenMatch[1]; } // Try alternative method - look for form inputs const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]'); if (csrfInput) { console.log("Found CSRF token from input:", csrfInput.value); return csrfInput.value; } console.log("Could not find CSRF token"); return null; } // Add this function to check if cookies are properly enabled and set function verifyCookies() { return new Promise((resolve, reject) => { // Try setting a test cookie document.cookie = "test_cookie=1; path=/;"; // Check if the cookie was set if (document.cookie.indexOf("test_cookie=1") === -1) { console.error("Cookies appear to be disabled or restricted"); reject(new Error("Cookies appear to be disabled or restricted")); return; } // Verify session cookies by making a simple request fetch("https://nhentai.net/", { method: "GET", credentials: "include" }) .then(response => { if (response.ok) { // Check if we're actually logged in by looking for specific elements in the response return response.text().then(html => { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); // If the menu-sign-in element is present, we're not properly logged in const signInElement = doc.querySelector('.menu-sign-in'); if (signInElement) { console.error("Session cookies not working correctly - not logged in"); reject(new Error("Session cookies not working correctly - not logged in")); } else { console.log("Cookies and session verified successfully"); resolve(true); } }); } else { console.error("Failed to verify session"); reject(new Error("Failed to verify session")); } }) .catch(error => { console.error("Error verifying cookies:", error); reject(error); }); }); } // Modify the processFavorites function to check cookies first async function processFavorites(favorites) { if (window.location.href.startsWith("https://nhentai.net/login/")) { return; } console.log("Processing stored favorites:", favorites); // Verify cookies before proceeding try { await verifyCookies(); } catch (error) { console.error("Cookie verification failed:", error); showPopup(`Cannot process favorites: ${error.message}. Try logging in again.`, { timeout: 5000, width: '300px' }); return; } // Create and show a popup with progress information const progressPopup = showPopup(`Processing favorites: 0/${favorites.length}`, { autoClose: false, width: '300px', buttons: [ { text: "Cancel", callback: () => { // User canceled processing processingCanceled = true; } } ] }); const successfulOnes = []; const failedOnes = []; let processingCanceled = false; for (let i = 0; i < favorites.length; i++) { if (processingCanceled) { progressPopup.updateMessage(`Processing canceled. Completed: ${successfulOnes.length}/${favorites.length}`); break; } const mangaId = favorites[i]; // Update progress in popup progressPopup.updateMessage(`Processing favorites: ${i+1}/${favorites.length}`); try { await sendFavoriteRequest(mangaId); console.log("Successfully favorited manga:", mangaId); successfulOnes.push(mangaId); } catch (error) { console.error("Error favoriting manga:", mangaId, error); failedOnes.push(mangaId); } // Small delay to avoid hammering the server await new Promise(resolve => setTimeout(resolve, 500)); } if (successfulOnes.length > 0) { let offlineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(offlineFavorites)) offlineFavorites = []; const offlineSet = new Set(offlineFavorites); for (const id of successfulOnes) { if (!offlineSet.has(id)) { offlineFavorites.push(id); offlineSet.add(id); } } await GM.setValue('offlineFavorites', offlineFavorites); let toUnfavorite = await GM.getValue('toUnfavorite', []); if (!Array.isArray(toUnfavorite)) toUnfavorite = []; toUnfavorite = toUnfavorite.filter(id => !successfulOnes.includes(id)); await GM.setValue('toUnfavorite', toUnfavorite); } // Keep only the failed ones in storage if (failedOnes.length > 0) { await GM.setValue('toFavorite', failedOnes); console.log("Updated stored favorites with failed ones:", failedOnes); } else { // Clear stored favorites after processing await GM.setValue('toFavorite', []); console.log("Cleared stored favorites"); } // Update final result in popup progressPopup.updateMessage(`Completed: ${successfulOnes.length} successful, ${failedOnes.length} failed`); // Close the popup progressPopup.close(); // Show a summary popup that auto-closes showPopup(`Completed: ${successfulOnes.length} successful, ${failedOnes.length} failed`, { timeout: 5000, width: '300px', buttons: [ { text: "OK", callback: () => {} } ] }); } async function processUnfavorites(unfavorites) { if (window.location.href.startsWith("https://nhentai.net/login/")) { return; } console.log("Processing stored unfavorites:", unfavorites); try { await verifyCookies(); } catch (error) { console.error("Cookie verification failed:", error); showPopup(`Cannot process unfavorites: ${error.message}. Try logging in again.`, { timeout: 5000, width: '300px' }); return; } const progressPopup = showPopup(`Processing unfavorites: 0/${unfavorites.length}`, { autoClose: false, width: '300px', buttons: [ { text: "Cancel", callback: () => { processingCanceled = true; } } ] }); const successfulOnes = []; const failedOnes = []; let processingCanceled = false; for (let i = 0; i < unfavorites.length; i++) { if (processingCanceled) { progressPopup.updateMessage(`Processing canceled. Completed: ${successfulOnes.length}/${unfavorites.length}`); break; } const mangaId = unfavorites[i]; progressPopup.updateMessage(`Processing unfavorites: ${i+1}/${unfavorites.length}`); try { await sendUnfavoriteRequest(mangaId); console.log("Successfully unfavorited manga:", mangaId); successfulOnes.push(mangaId); } catch (error) { console.error("Error unfavoriting manga:", mangaId, error); failedOnes.push(mangaId); } await new Promise(resolve => setTimeout(resolve, 500)); } if (successfulOnes.length > 0) { let offlineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(offlineFavorites)) offlineFavorites = []; offlineFavorites = offlineFavorites.filter(id => !successfulOnes.includes(id)); await GM.setValue('offlineFavorites', offlineFavorites); let toFavorite = await GM.getValue('toFavorite', []); if (!Array.isArray(toFavorite)) toFavorite = []; toFavorite = toFavorite.filter(id => !successfulOnes.includes(id)); await GM.setValue('toFavorite', toFavorite); } if (failedOnes.length > 0) { await GM.setValue('toUnfavorite', failedOnes); console.log("Updated stored unfavorites with failed ones:", failedOnes); } else { await GM.setValue('toUnfavorite', []); console.log("Cleared stored unfavorites"); } progressPopup.updateMessage(`Completed: ${successfulOnes.length} successful, ${failedOnes.length} failed`); progressPopup.close(); showPopup(`Completed: ${successfulOnes.length} successful, ${failedOnes.length} failed`, { timeout: 5000, width: '300px', buttons: [ { text: "OK", callback: () => {} } ] }); } init(); //--------------------------**Offline Favoriting**---------------------------------------------- //-----------------------------------------------------NFM-Debugging------------------------------------------------------------------ // Add this function to create a settings menu async function createSettingsMenu() { const nfmPageEnabled = await GM.getValue('nfmPageEnabled', true); if (!nfmPageEnabled) return; // Create settings button const nav = document.querySelector('nav .menu.left'); if (!nav) return; const settingsLi = document.createElement('li'); settingsLi.className = 'desktop'; const settingsLink = document.createElement('a'); settingsLink.href = '#'; settingsLink.innerHTML = '<i class="fas fa-cog" style="color:pink;"></i> NFM'; settingsLi.appendChild(settingsLink); nav.appendChild(settingsLi); // Create settings popup settingsLink.addEventListener('click', async (e) => { e.preventDefault(); const offlineFavoritingEnabled = await GM.getValue('offlineFavoritingEnabled', true); const toFavorite = await GM.getValue('toFavorite', []); const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const content = ` <div style="padding: 1rem;"> <h3>NHentai Favorite Manager Settings</h3> <div style="margin-bottom: 1rem;"> <label> <input type="checkbox" id="nfm-offline-favoriting" ${offlineFavoritingEnabled ? 'checked' : ''}> Offline Favoriting </label> </div> <div style="margin-bottom: 1rem;"> <p>Pending favorites: ${toFavorite.length}</p> <button id="nfm-clear-favorites" class="btn btn-secondary">Clear Pending Favorites</button> <button id="nfm-process-favorites" class="btn btn-primary">Process Now</button> </div> <div style="margin-bottom: 1rem;"> <h4>Debug Info</h4> <p>iOS Device: ${isIOSDevice ? 'Yes' : 'No'}</p> <p>Logged In: ${!document.querySelector('.menu-sign-in') ? 'Yes' : 'No'}</p> <p>Cookies Enabled: ${navigator.cookieEnabled ? 'Yes' : 'No'}</p> <button id="nfm-test-request" class="btn btn-secondary">Test API Request</button> </div> </div> `; const popup = showPopup(content, { autoClose: false, width: '400px', buttons: [ { text: "Close", callback: () => {} } ] }); // Add event listeners document.getElementById('nfm-offline-favoriting').addEventListener('change', async (e) => { await GM.setValue('offlineFavoritingEnabled', e.target.checked); console.log("Offline favoriting enabled:", e.target.checked); }); document.getElementById('nfm-clear-favorites').addEventListener('click', async () => { await GM.setValue('toFavorite', []); console.log("Cleared pending favorites"); popup.updateMessage('Pending favorites cleared!'); setTimeout(() => popup.close(), 1500); }); document.getElementById('nfm-process-favorites').addEventListener('click', async () => { popup.close(); const toFavorite = await GM.getValue('toFavorite', []); if (toFavorite.length > 0) { await processFavorites(toFavorite); } else { showPopup("No pending favorites to process.", { timeout: 2000, width: '300px' }); } }); document.getElementById('nfm-test-request').addEventListener('click', async () => { console.log("Testing API request..."); try { await verifyCookies(); showPopup("Cookie test successful!", { timeout: 2000, width: '300px' }); } catch (error) { showPopup(`Cookie test failed: ${error.message}`, { timeout: 4000, width: '300px' }); } }); }); } // Add this to your init function createSettingsMenu(); //-----------------------------------------------------NFM-Debugging------------------------------------------------------------------ //-------------------------------------------------**Delete-Twitter-Button**----------------------------------------------- async function deleteTwitterButton() { const twitterButtonEnabled = await GM.getValue('twitterButtonEnabled', true); if (!twitterButtonEnabled) return; $('a[href="https://twitter.com/nhentaiOfficial"]').remove(); } deleteTwitterButton(); //-------------------------------------------------**Delete-Twitter-Button**----------------------------------------------- //-------------------------------------------------**Delete-Info-Button**----------------------------------------------- async function deleteInfoButton() { const infoButtonEnabled = await GM.getValue('infoButtonEnabled', true); if (!infoButtonEnabled) return; $("a[href='/info/']").remove(); } //Call the function to execute deleteInfoButton(); //-------------------------------------------------**Delete-Info-Button**----------------------------------------------- //-------------------------------------------------**Delete-Profile-Button**----------------------------------------------- async function deleteProfileButton() { const profileButtonEnabled = await GM.getValue('profileButtonEnabled', true); if (!profileButtonEnabled) return; $("li a[href^='/users/']").remove(); } //Call the function to execute. deleteProfileButton(); //-------------------------------------------------**Delete-Profile-Button**----------------------------------------------- //-------------------------------------------------**Delete-Logout-Button**----------------------------------------------- async function deleteLogoutButton() { const logoutButtonEnabled = await GM.getValue('logoutButtonEnabled', true); if (!logoutButtonEnabled) return; $("li a[href='/logout/?next=/settings/']").parent().remove(); } deleteLogoutButton(); //-------------------------------------------------**Delete-Logout-Button**----------------------------------------------- //-------------------------------------------------**BookMark-Link**--------------------------------------------------------- async function createBookmarkLink() { const bookmarkLinkEnabled = await GM.getValue('bookmarkLinkEnabled', true); if (!bookmarkLinkEnabled) return; // Extract current manga ID from URL const currentMangaId = window.location.pathname.split('/')[2]; // Get all GM keys const allKeys = await GM.listValues(); // Filter bookmark keys and check for current ID let bookmarkUrl = null; for (const key of allKeys) { if (key.startsWith('bookmark_manga_ids_')) { const mangaIds = await GM.getValue(key, []); if (mangaIds.includes(currentMangaId)) { // Extract original bookmark URL from key bookmarkUrl = key.replace('bookmark_manga_ids_', ''); break; } } } // Update title if bookmark found if (bookmarkUrl) { const $title = $('h1.title'); const linkHtml = `<a href="${bookmarkUrl}" class="bookmark-link" style="color: inherit; text-decoration: none;"><u>${$title.html()}</u></a>`; $title.html(linkHtml).css('cursor', 'pointer'); } } createBookmarkLink(); //-------------------------------------------------**BookMark-Link**--------------------------------------------------------- //-------------------------------------------------**Offline-Favorites-Page**--------------------------------------------------------- // Function to handle the offline favorites page async function handleOfflineFavoritesPage() { // Check if we're on the favorites page if (window.location.pathname !== '/favorite/' && window.location.pathname !== '/favorite') { return; } // Check if the feature is enabled const offlineFavoritesPageEnabled = await GM.getValue('offlineFavoritesPageEnabled', true); if (!offlineFavoritesPageEnabled) { return; } // Remove any 404 elements const notFoundHeading = document.querySelector('h1'); if (notFoundHeading?.textContent === '404 – Not Found') { notFoundHeading.remove(); } const notFoundMessage = document.querySelector('p'); if (notFoundMessage?.textContent === "Looks like what you're looking for isn't here.") { notFoundMessage.remove(); } async function getCombinedFavorites() { const offlineFavorites = await GM.getValue('offlineFavorites', []); const pendingFavorites = await GM.getValue('toFavorite', []); const combinedFavorites = []; const seen = new Set(); for (const id of Array.isArray(offlineFavorites) ? offlineFavorites : []) { if (!seen.has(id)) { combinedFavorites.push(id); seen.add(id); } } for (const id of Array.isArray(pendingFavorites) ? pendingFavorites : []) { if (!seen.has(id)) { combinedFavorites.push(id); seen.add(id); } } return combinedFavorites; } let combinedFavorites = await getCombinedFavorites(); // Create container for favorites const container = document.createElement('div'); container.className = 'container'; container.id = 'offline-favorites-container'; // Create heading const heading = document.createElement('h1'); heading.textContent = 'Offline Favorites'; container.appendChild(heading); // Create description const description = document.createElement('p'); description.textContent = 'These are manga you have favorited while offline. They will be synced to your account when you log in.'; container.appendChild(description); // Create sort controls const sortControls = document.createElement('div'); sortControls.className = 'sort-controls'; sortControls.innerHTML = ` <label> Sort by: <select id="sort-favorites"> <option value="newest">Newest First</option> <option value="oldest">Oldest First</option> </select> </label> `; container.appendChild(sortControls); // Create gallery container const galleryContainer = document.createElement('div'); galleryContainer.className = 'gallery-container'; container.appendChild(galleryContainer); // Add container to page document.getElementById('content').appendChild(container); // Add CSS styles // Add CSS styles GM.addStyle(` #offline-favorites-container { padding: 20px 0; max-width: 1200px; margin: 0 auto; } #offline-favorites-container h1 { margin-bottom: 10px; color: #ed2553; font-size: 2em; } #offline-favorites-container p { margin-bottom: 20px; color: #888; font-size: 1em; } .sort-controls { margin-bottom: 20px; } .sort-controls select { background-color: #252525; color: #f1f1f1; border: 1px solid #3d3d3d; padding: 8px 12px; border-radius: 3px; cursor: pointer; font-size: 14px; } .sort-controls select:hover { background-color: #3d3d3d; } .gallery-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 25px; padding: 0 10px; } .favorite-item { position: relative; border-radius: 3px; overflow: hidden; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); transition: all 0.3s ease; background: #252525; } .favorite-item:hover { transform: translateY(-5px); box-shadow: 0 5px 15px rgba(237, 37, 83, 0.2); } .favorite-item img { width: 100%; height: auto; display: block; aspect-ratio: 3/4; object-fit: cover; } .favorite-item .title { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0, 0, 0, 0.9)); color: #ffffff; padding: 15px 10px; font-size: 14px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .favorite-item .remove-btn { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%; width: 24px; height: 24px; font-size: 14px; cursor: pointer; opacity: 0; transition: opacity 0.2s ease; text-align: center; display: flex; align-items: center; justify-content: center; } @media (max-width: 768px) { .favorite-item .remove-btn { width: 12px; height: 12px; font-size: 12px; } } .favorite-item:hover .remove-btn { opacity: 1; } .favorite-item .remove-btn:hover { background: #ed2553; transform: scale(1.1); } .no-favorites { grid-column: 1 / -1; text-align: center; padding: 80px 0; color: #888; font-size: 1.2em; background: #252525; border-radius: 5px; margin: 20px 0; } @media (max-width: 768px) { .gallery-container { grid-template-columns: repeat(auto-fill, minmax(115px, .5fr)); gap: 15px; } #offline-favorites-container h1 { font-size: 1.5em; text-align: center; } } `); // Function to render favorites async function renderFavorites(favorites, sortOrder = 'newest') { galleryContainer.innerHTML = ''; if (favorites.length === 0) { const noFavorites = document.createElement('div'); noFavorites.className = 'no-favorites'; noFavorites.textContent = 'No offline favorites found. Add some by clicking the heart icon on manga pages.'; galleryContainer.appendChild(noFavorites); return; } // Sort favorites if needed let sortedFavorites = [...favorites]; if (sortOrder === 'newest') { sortedFavorites.reverse(); } // Create a fragment to improve performance const fragment = document.createDocumentFragment(); // Process each favorite for (const mangaId of sortedFavorites) { const favoriteItem = document.createElement('div'); favoriteItem.className = 'favorite-item'; favoriteItem.dataset.id = mangaId; // Create link to manga const link = document.createElement('a'); link.href = `https://nhentai.net/g/${mangaId}/`; // Create thumbnail image (placeholder initially) const img = document.createElement('img'); img.src = 'https://i.nhentai.net/galleries/0/placeholder.jpg'; // Placeholder img.alt = `Manga ${mangaId}`; link.appendChild(img); // Create title element (will be updated with actual title) const title = document.createElement('div'); title.className = 'title'; title.textContent = `Loading...`; link.appendChild(title); // Create remove button const removeBtn = document.createElement('button'); removeBtn.className = 'remove-btn'; removeBtn.innerHTML = '✖'; removeBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); // Show confirmation popup before deleting showPopup('Are you sure you want to remove this from favorites?', { autoClose: false, width: '300px', buttons: [ { text: "Cancel", callback: () => { // Do nothing, just close the popup } }, { text: "Remove", callback: async () => { // Store the manga ID for potential undo const deletedMangaId = mangaId; let deletedMangaInfo = null; try { // Try to get manga info for the undo popup deletedMangaInfo = await GM.getValue(`manga_info_${mangaId}`, null); } catch (error) { console.error("Error getting manga info for undo:", error); } let currentOfflineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(currentOfflineFavorites)) currentOfflineFavorites = []; let currentPendingFavorites = await GM.getValue('toFavorite', []); if (!Array.isArray(currentPendingFavorites)) currentPendingFavorites = []; let currentPendingUnfavorites = await GM.getValue('toUnfavorite', []); if (!Array.isArray(currentPendingUnfavorites)) currentPendingUnfavorites = []; const wasPending = currentPendingFavorites.includes(deletedMangaId); currentOfflineFavorites = currentOfflineFavorites.filter(id => id !== deletedMangaId); currentPendingFavorites = currentPendingFavorites.filter(id => id !== deletedMangaId); await GM.setValue('offlineFavorites', currentOfflineFavorites); await GM.setValue('toFavorite', currentPendingFavorites); if (!wasPending && !currentPendingUnfavorites.includes(deletedMangaId)) { currentPendingUnfavorites.push(deletedMangaId); } await GM.setValue('toUnfavorite', currentPendingUnfavorites); // Remove from display favoriteItem.remove(); const combinedFavoritesAfterDelete = []; const seenAfterDelete = new Set(); for (const id of currentOfflineFavorites) { if (!seenAfterDelete.has(id)) { combinedFavoritesAfterDelete.push(id); seenAfterDelete.add(id); } } for (const id of currentPendingFavorites) { if (!seenAfterDelete.has(id)) { combinedFavoritesAfterDelete.push(id); seenAfterDelete.add(id); } } combinedFavorites = combinedFavoritesAfterDelete; renderFavorites(combinedFavoritesAfterDelete, sortOrder); // Show confirmation with undo button showPopup( `Removed from favorites${deletedMangaInfo?.title ? ': ' + deletedMangaInfo.title : ''}`, { timeout: 5000, width: '300px', buttons: [ { text: "Undo", callback: async () => { let undoOfflineFavorites = await GM.getValue('offlineFavorites', []); if (!Array.isArray(undoOfflineFavorites)) undoOfflineFavorites = []; let undoPendingFavorites = await GM.getValue('toFavorite', []); if (!Array.isArray(undoPendingFavorites)) undoPendingFavorites = []; let undoPendingUnfavorites = await GM.getValue('toUnfavorite', []); if (!Array.isArray(undoPendingUnfavorites)) undoPendingUnfavorites = []; if (!undoOfflineFavorites.includes(deletedMangaId)) { undoOfflineFavorites.push(deletedMangaId); } if (wasPending && !undoPendingFavorites.includes(deletedMangaId)) { undoPendingFavorites.push(deletedMangaId); } undoPendingUnfavorites = undoPendingUnfavorites.filter(id => id !== deletedMangaId); await GM.setValue('offlineFavorites', undoOfflineFavorites); await GM.setValue('toFavorite', undoPendingFavorites); await GM.setValue('toUnfavorite', undoPendingUnfavorites); const combinedFavoritesAfterUndo = []; const seenAfterUndo = new Set(); for (const id of undoOfflineFavorites) { if (!seenAfterUndo.has(id)) { combinedFavoritesAfterUndo.push(id); seenAfterUndo.add(id); } } for (const id of undoPendingFavorites) { if (!seenAfterUndo.has(id)) { combinedFavoritesAfterUndo.push(id); seenAfterUndo.add(id); } } combinedFavorites = combinedFavoritesAfterUndo; renderFavorites(combinedFavoritesAfterUndo, sortOrder); showPopup('Favorite restored', { timeout: 2000 }); } } ] } ); } } ] }); }); favoriteItem.appendChild(link); favoriteItem.appendChild(removeBtn); fragment.appendChild(favoriteItem); // Fetch manga info asynchronously fetchMangaInfo(mangaId).then(info => { if (info) { // Update thumbnail if (info.thumbnail) { img.src = info.thumbnail; } // Update title if (info.title) { title.textContent = info.title; } } }).catch(error => { console.error(`Error fetching info for manga ${mangaId}:`, error); }); } galleryContainer.appendChild(fragment); } // Function to fetch manga info async function fetchMangaInfo(mangaId) { try { // Try to get from cache first const cachedInfo = await GM.getValue(`manga_info_${mangaId}`, null); if (cachedInfo) { return cachedInfo; } // Fetch from API const response = await fetch(`https://nhentai.net/g/${mangaId}/`); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Extract title let title = ''; const titleElement = doc.querySelector('.title'); if (titleElement) { const prettyElement = titleElement.querySelector('.pretty'); title = prettyElement ? prettyElement.textContent : titleElement.textContent; } // Extract thumbnail let thumbnail = ''; const coverImg = doc.querySelector('#cover img'); if (coverImg) { thumbnail = coverImg.getAttribute('data-src') || coverImg.getAttribute('src'); } // Create info object const info = { title, thumbnail }; // Cache the info await GM.setValue(`manga_info_${mangaId}`, info); return info; } catch (error) { console.error(`Error fetching manga info for ${mangaId}:`, error); return null; } } // Initialize with current favorites renderFavorites(combinedFavorites); // Add event listener for sort control document.getElementById('sort-favorites').addEventListener('change', async function() { combinedFavorites = await getCombinedFavorites(); renderFavorites(combinedFavorites, this.value); }); } // Call the function to handle the favorites page handleOfflineFavoritesPage(); //-------------------------------------------------**Offline-Favorites-Page**--------------------------------------------------------- //-------------------------------------------------**Non-English-Manga**-------------------------------------------------------- async function applyNonEnglishStyles() { // Remove existing styles $('style[data-non-english]').remove(); const showNonEnglish = await GM.getValue('showNonEnglish', 'show'); let style = ''; if (showNonEnglish === 'hide') { style = `.gallery:not([data-tags~='12227']) { display: none; }`; } else if (showNonEnglish === 'fade') { const nonEnglishFadeOpacity = 0.5; // Or get this from settings style = `.gallery:not([data-tags~='12227']) > .cover > img, .gallery:not([data-tags~='12227']) > .cover > .caption { opacity: ${nonEnglishFadeOpacity}; }`; } if (style) { const newStyle = document.createElement('style'); newStyle.dataset.nonEnglish = true; newStyle.innerHTML = style; document.head.appendChild(newStyle); } } applyNonEnglishStyles(); // Apply styles on initial load //-------------------------------------------------**Non-English-Manga**-------------------------------------------------------- // -----------------------------------------------**Thumbnail-Page-Numbers**-------------------------------------------------------- // Function to add page numbers to manga thumbnails async function addPageNumbersToThumbnails() { // Check if the feature is enabled const showPageNumbersEnabled = await GM.getValue('showPageNumbersEnabled', true); if (!showPageNumbersEnabled) return; // Add CSS for page number display const pageNumberCSS = ` .page-number-display { position: absolute; top: 5px; right: 5px; background-color: rgba(0, 0, 0, 0.7); color: white; padding: 2px 6px; border-radius: 3px; font-size: 12px; font-weight: bold; z-index: 10; background-color: rgba(0,0,0,.4); opacity: 1; } `; GM.addStyle(pageNumberCSS); // Function to extract page count from a manga page async function getPageCount(url) { try { const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Look for the page count in the tags section const pageElement = doc.querySelector('#tags .tag-container:nth-last-child(2) .name'); if (pageElement) { const pageCount = parseInt(pageElement.textContent.trim(), 10); if (!isNaN(pageCount)) { return pageCount; } } return null; } catch (error) { console.error('Error fetching page count:', error); return null; } } // Function to add page number display to a gallery item async function addPageNumberToGallery(galleryItem) { // Get the manga URL first const coverLink = galleryItem.querySelector('.cover'); if (!coverLink) return; // Check if this specific cover link already has a page number display if (coverLink.querySelector('.page-number-display')) { return; } // Mark this cover as being processed to prevent race conditions if (coverLink.dataset.pageNumberProcessing === 'true') { return; } coverLink.dataset.pageNumberProcessing = 'true'; const mangaUrl = coverLink.getAttribute('href'); if (!mangaUrl) { coverLink.dataset.pageNumberProcessing = 'false'; return; } const fullUrl = `https://nhentai.net${mangaUrl}`; try { // Try to get page count const pageCount = await getPageCount(fullUrl); if (pageCount) { // Double-check that no page number display was added while we were fetching if (!coverLink.querySelector('.page-number-display')) { // Create and add the page number display const pageNumberDisplay = document.createElement('div'); pageNumberDisplay.className = 'page-number-display'; pageNumberDisplay.textContent = `${pageCount} ${pageCount === 1 ? 'page' : 'pages'}`; // Add to the cover element coverLink.style.position = 'relative'; coverLink.appendChild(pageNumberDisplay); } } } finally { // Always clear the processing flag coverLink.dataset.pageNumberProcessing = 'false'; } } // Process all gallery items on the page function processGalleryItems() { const galleryItems = document.querySelectorAll('.gallery'); galleryItems.forEach(galleryItem => { addPageNumberToGallery(galleryItem); }); } // Initial processing processGalleryItems(); // Set up a MutationObserver to handle dynamically added content const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { // Check if the added node is a gallery or contains galleries if (node.classList && node.classList.contains('gallery')) { addPageNumberToGallery(node); } else if (node.querySelectorAll) { const galleries = node.querySelectorAll('.gallery'); galleries.forEach(gallery => { addPageNumberToGallery(gallery); }); } }); } }); }); // Start observing the document with the configured parameters observer.observe(document.body, { childList: true, subtree: true }); } // Call the function to add page numbers to thumbnails addPageNumbersToThumbnails(); // -----------------------------------------------**Thumbnail-Page-Numbers**-------------------------------------------------------- // -----------------------------------------------**Background Max Manga Sync**-------------------------------------------------------- // Wait for the HTML document to be fully loaded setTimeout(async function() { // Function to fetch and process bookmarked pages async function processBookmarkedPages(isBackgroundProcess = false) { // Get all bookmarked pages from storage const bookmarkedPages = await GM.getValue('bookmarkedPages', []); // Get the max manga per bookmark from the slider const maxMangaPerBookmark = await GM.getValue('maxMangaPerBookmark', 5); // Get the last processing timestamp to avoid processing too frequently const lastProcessTime = await GM.getValue('lastBookmarkProcessTime', 0); const now = new Date().getTime(); // If this is a background process, only run if it's been at least 1 hour since last run if (isBackgroundProcess && (now - lastProcessTime < 3600000)) { console.log('Background processing skipped - last run was less than 1 hour ago'); return; } // Always get bookmarks from storage, but also check DOM if we're on the bookmarks page let bookmarkLinks = []; // First, always use the stored bookmarked pages as the primary source bookmarkLinks = bookmarkedPages.map(url => ({ href: url })); // If we're on the bookmarks page, also check the DOM for any new bookmarks if (!isBackgroundProcess && window.location.href.includes('/bookmarks')) { const domBookmarkLinks = document.querySelectorAll('.bookmarks-list .bookmark-link'); console.log('Found bookmark links in DOM:', domBookmarkLinks.length); // Convert DOM collection to array and add any links not already in our list Array.from(domBookmarkLinks).forEach(link => { if (link.href && !bookmarkLinks.some(existing => existing.href === link.href)) { bookmarkLinks.push(link); } }); } console.log('Processing bookmarks:', bookmarkLinks.length); console.log('Max manga per bookmark setting:', maxMangaPerBookmark); if (bookmarkLinks.length === 0) { console.log('No bookmark links found'); return; } // Log the fetched bookmarked URLs console.log('Processing bookmarked URLs:'); // Store the current time as the last processing time // await GM.setValue('lastBookmarkProcessTime', now); // Track rate limit status let isRateLimited = false; let rateLimitResetTime = 0; // Request each bookmark URL and extract manga URLs for (const link of bookmarkLinks) { if (!link.href) { console.log('Bookmark link has no href attribute, skipping'); continue; } // If we're rate limited and the reset time hasn't passed, skip processing if (isRateLimited && now < rateLimitResetTime) { console.log(`Skipping processing due to rate limit. Will reset at ${new Date(rateLimitResetTime).toLocaleTimeString()}`); continue; } // Check if bookmark has existing cache const existingCache = await GM.getValue(`bookmark_manga_ids_${link.href}`); if (existingCache && !isBackgroundProcess) { console.log(`Skipping bookmark ${link.href} as it has existing cache`); continue; } console.log(`Processing bookmark: ${link.href}`); try { // Fetch the bookmark page with retry logic const bookmarkResponse = await fetchWithRetry(link.href); // Check for rate limiting headers const rateLimitRemaining = bookmarkResponse.headers.get('X-RateLimit-Remaining'); const rateLimitReset = bookmarkResponse.headers.get('X-RateLimit-Reset'); if (rateLimitRemaining === '0' && rateLimitReset) { isRateLimited = true; rateLimitResetTime = parseInt(rateLimitReset) * 1000; // Convert to milliseconds console.log(`Rate limit reached. Will reset at ${new Date(rateLimitResetTime).toLocaleTimeString()}`); } const html = await bookmarkResponse.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); // Extract all manga URLs from the page (main gallery thumbnails) const mangaLinks = doc.querySelectorAll('.gallery a.cover'); const allMangaUrls = Array.from(mangaLinks).map(link => { return { url: 'https://nhentai.net' + link.getAttribute('href'), id: link.getAttribute('href').split('/g/')[1].replace('/', '') }; }); // Store the complete list of manga IDs for this bookmark await GM.setValue(`bookmark_manga_ids_${link.href}`, allMangaUrls.map(item => item.id)); // Apply limit if maxMangaPerBookmark is valid const limitToApply = (!isNaN(maxMangaPerBookmark) && maxMangaPerBookmark > 0) ? maxMangaPerBookmark : allMangaUrls.length; // Slice the array to the appropriate length const mangaToProcess = allMangaUrls.slice(0, limitToApply); // Log the fetched manga URLs from each bookmark with limit info console.log(`Found ${allMangaUrls.length} manga in bookmark, processing ${mangaToProcess.length} (limit: ${limitToApply})`); // Fetch and process tags for each manga URL (limited by maxMangaPerBookmark) for (const manga of mangaToProcess) { // Check if we've hit a rate limit during processing if (isRateLimited && now < rateLimitResetTime) { console.log(`Pausing manga processing due to rate limit. Will resume later.`); break; } const mangaId = manga.id; const mangaUrl = manga.url; // Use a simpler cache key that only depends on the manga ID let mangaInfo = await GM.getValue(`manga_${mangaId}`, null); // Track when this manga was last seen const currentTime = new Date().getTime(); if (!mangaInfo) { console.log(`Fetching new manga info for ID: ${mangaId}, URL: ${mangaUrl}`); try { // Fetch the manga page with retry logic const mangaResponse = await fetchWithRetry(mangaUrl); // Check for rate limiting headers const mangaRateLimitRemaining = mangaResponse.headers.get('X-RateLimit-Remaining'); const mangaRateLimitReset = mangaResponse.headers.get('X-RateLimit-Reset'); if (mangaRateLimitRemaining === '0' && mangaRateLimitReset) { isRateLimited = true; rateLimitResetTime = parseInt(mangaRateLimitReset) * 1000; // Convert to milliseconds console.log(`Rate limit reached during manga fetch. Will reset at ${new Date(rateLimitResetTime).toLocaleTimeString()}`); } const html = await mangaResponse.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const tagsList = doc.querySelectorAll('#tags .tag'); // Extract artist names specifically try { const tagGroups = Array.from(doc.querySelectorAll('#tags .tag-container')); const artistGroup = tagGroups.find(c => { const fn = c.querySelector('.field-name'); return fn && /artists/i.test(fn.textContent); }); if (artistGroup) { const artists = Array.from(artistGroup.querySelectorAll('a.tag .name')).map(el => el.textContent.trim()); if (artists.length) { addKnownArtists(artists); } } } catch (_) {} // Get the title const titleElement = doc.querySelector('h1.title'); const title = titleElement ? titleElement.textContent.trim() : null; if (tagsList.length > 0) { const tags = Array.from(tagsList).map(tag => tag.textContent.trim()); console.log(`Fetched tags for ${mangaUrl}:`, tags); await addKnownMultiwordTags(tags); mangaInfo = { id: mangaId, url: mangaUrl, title: title, tags: tags, lastSeen: currentTime, bookmarks: [link.href] // Track which bookmarks this manga appears in }; } else { console.log(`No tags found for ${mangaUrl}`); mangaInfo = { id: mangaId, url: mangaUrl, title: title, tags: [], lastSeen: currentTime, bookmarks: [link.href] }; } await GM.setValue(`manga_${mangaId}`, mangaInfo); } catch (error) { console.error(`Error fetching tags for: ${mangaUrl}`, error); mangaInfo = { id: mangaId, url: mangaUrl, tags: [], lastSeen: currentTime, bookmarks: [link.href] }; await GM.setValue(`manga_${mangaId}`, mangaInfo); // Check if the error was due to rate limiting if (error.message && error.message.includes('429')) { isRateLimited = true; rateLimitResetTime = currentTime + 300000; // Wait 5 minutes by default console.log(`Rate limit detected from error. Pausing for 5 minutes.`); } } } else { // Update the existing manga info with the current timestamp // and add this bookmark if not already present if (!mangaInfo.bookmarks.includes(link.href)) { mangaInfo.bookmarks.push(link.href); } mangaInfo.lastSeen = currentTime; await GM.setValue(`manga_${mangaId}`, mangaInfo); console.log(`Updated existing manga cache for ${mangaId}`); } // Add a small delay between manga requests to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 500)); } } catch (error) { console.error(`Error processing bookmark: ${link.href}`, error); // Check if the error was due to rate limiting if (error.message && error.message.includes('429')) { isRateLimited = true; rateLimitResetTime = now + 300000; // Wait 5 minutes by default console.log(`Rate limit detected from error. Pausing for 5 minutes.`); } } // Add a delay between bookmark processing to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 1000)); } // Optional: clean up old cached manga data that hasn't been seen in a while await cleanupOldCacheData(30); // Clean data older than 30 days console.log('Bookmark processing completed'); // Store the current time as the last processing time AFTER processing is complete // Use current time instead of the 'now' variable from the beginning of the function await GM.setValue('lastBookmarkProcessTime', new Date().getTime()); } // Helper function to clean up old cache data async function cleanupOldCacheData(daysOld) { try { const allKeys = await GM.listValues(); const mangaKeys = allKeys.filter(key => key.startsWith('manga_')); const now = new Date().getTime(); const cutoffTime = now - (daysOld * 24 * 60 * 60 * 1000); // Convert days to milliseconds let removedCount = 0; for (const key of mangaKeys) { const mangaInfo = await GM.getValue(key); // If there's no lastSeen or if it's older than the cutoff, remove it if (!mangaInfo || !mangaInfo.lastSeen || mangaInfo.lastSeen < cutoffTime) { await GM.deleteValue(key); removedCount++; } } if (removedCount > 0) { console.log(`Cleaned up ${removedCount} old manga entries from cache`); } } catch (error) { console.error('Error cleaning up old cache data:', error); } } // Helper function to fetch with retry logic for 429 errors async function fetchWithRetry(url, maxRetries = 10, initialDelay = 2000) { let retries = 0; let delay = initialDelay; while (retries < maxRetries) { try { const response = await fetch(url); // If we got a 429 Too Many Requests, retry after a delay if (response.status === 429) { retries++; console.log(`Rate limited (429) on ${url}. Retry ${retries}/${maxRetries} after ${delay}ms delay.`); // Check for Retry-After header const retryAfter = response.headers.get('Retry-After'); if (retryAfter) { // Retry-After is in seconds, convert to milliseconds delay = parseInt(retryAfter) * 1000; console.log(`Server specified Retry-After: ${retryAfter} seconds`); } await new Promise(resolve => setTimeout(resolve, delay)); // Increase delay for subsequent retries (exponential backoff) delay = Math.min(delay * 1.5, 30000); // Cap at 30 seconds } else { // For any other status, return the response return response; } } catch (error) { retries++; console.error(`Fetch error for ${url}. Retry ${retries}/${maxRetries}.`, error); if (retries >= maxRetries) throw error; await new Promise(resolve => setTimeout(resolve, delay)); // Increase delay for subsequent retries delay = Math.min(delay * 1.5, 30000); } } throw new Error(`Failed to fetch ${url} after ${maxRetries} retries.`); } // Helper function to update manga cache when limit changes async function updateMangaCache() { const maxMangaPerBookmark = await GM.getValue('maxMangaPerBookmark', 5); const allKeys = await GM.listValues(); const mangaKeys = allKeys.filter(key => key.startsWith('manga_')); for (const key of mangaKeys) { const mangaInfo = await GM.getValue(key); if (mangaInfo) { const newLimit = maxMangaPerBookmark; const existingLimit = mangaInfo.limit; if (newLimit !== existingLimit) { console.log(`Updating manga cache for ${mangaInfo.id} with new limit ${newLimit}`); mangaInfo.limit = newLimit; await GM.setValue(key, mangaInfo); } } } } // Set up periodic background processing function setupBackgroundProcessing() { // Process bookmarks in the background every hour setInterval(async () => { console.log('Starting background bookmark processing...'); await processBookmarkedPages(true); }, 3600000); // 1 hour in milliseconds // Also run once at startup after a short delay setTimeout(async () => { console.log('Running initial background bookmark processing...'); await processBookmarkedPages(true); }, 30000); // 30 seconds after page load } // Process bookmarks on any page, but with different behavior // If we're on the bookmarks page, process immediately with DOM integration // Otherwise, process in background mode after a short delay if (window.location.href.includes('/bookmarks')) { processBookmarkedPages(false); // Process with DOM integration } else { // On other pages, process in background mode after a short delay setTimeout(() => { processBookmarkedPages(true); // Process in background mode }, 5000); // 5 second delay to not interfere with page loading } // Set up background processing regardless of current page setupBackgroundProcessing(); // Removed: bootstrapKnownMultiwordTagsFromStorage(); (Smart Tag now relies on GitHub taxonomy) // Fetch taxonomy daily and assimilate known artists if (typeof taxonomyManager !== 'undefined' && !taxonomyManager.isMobile) { taxonomyManager.fetchIfStale().catch(e => console.warn('Taxonomy fetch failed', e)); } // Update manga cache when limit changes updateMangaCache(); }, 2000); //-----------------------------------------------**Background Max Manga Sync**-------------------------------------------------------- // -----------------------------------------------**Must-Add-Tags**----------------------------------------------------------------------- // Function to update the must-add tags list // Intercept both XHR and form submissions // Ensure this is defined globally or within a scope accessible to both handlers const originalOpen = XMLHttpRequest.prototype.open; async function addKnownMultiwordTags(tagsArray) { // No-op: Smart Tag relies solely on GitHub taxonomy } async function addKnownArtists(items) { // No-op: Smart Tag relies solely on GitHub taxonomy } async function bootstrapKnownMultiwordTagsFromStorage() { // No-op: legacy GM tag bootstrap removed } // Handle form submissions document.querySelector('form.search').addEventListener('submit', async function(e) { e.preventDefault(); const searchInput = this.querySelector('input[name="q"]'); let raw = (searchInput.value || '').trim(); let smartTagEnabled = await GM.getValue('smartTagEnabled', true); const smartTagBypassOnce = await GM.getValue('smartTagBypassOnce', false); if (smartTagBypassOnce) { await GM.setValue('smartTagBypassOnce', false); smartTagEnabled = false; } const mustAddTagsEnabled = await GM.getValue('mustAddTagsEnabled', false); const mustAddTags = (await GM.getValue('mustAddTags', [])).map(tag => tag.toLowerCase()); if (typeof taxonomyManager !== 'undefined') { await taxonomyManager.ensureLoaded(); } function isLanguage(str) { const s = str.toLowerCase(); return s === 'english' || s === 'japanese' || s === 'chinese'; } let finalQuery = raw; if (smartTagEnabled && raw && !/[":]/.test(raw)) { const wordsAll = raw.trim().split(/\s+/).filter(Boolean); const jpParticles = new Set(["ga","de","no","ni","wa","to","e","o","ya","kara","made","shite","suru"]); const hasParticles = wordsAll.some(w => jpParticles.has(w.toLowerCase())); const likelyTitle = wordsAll.length >= 5 || hasParticles; async function promptForAmbiguity(name, types) { return new Promise(async (resolve) => { const existing = document.getElementById('smarttag-ambiguity-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'smarttag-ambiguity-modal'; overlay.style.position = 'fixed'; overlay.style.inset = '0'; overlay.style.background = 'rgba(0, 0, 0, 0.8)'; overlay.style.zIndex = '9999'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; const box = document.createElement('div'); box.style.background = '#222'; box.style.color = '#fff'; box.style.border = '1px solid #444'; box.style.borderRadius = '6px'; box.style.boxShadow = '0 8px 24px rgba(0,0,0,0.45)'; box.style.maxWidth = '460px'; box.style.width = '92%'; box.style.padding = '16px'; box.style.fontFamily = 'Segoe UI, system-ui, -apple-system, Roboto, Arial'; const title = document.createElement('h3'); title.textContent = `Choose type for "${name}"`; title.style.margin = '0 0 10px 0'; title.style.color = '#fff'; const desc = document.createElement('p'); desc.textContent = 'Multiple taxonomy types match this name. Select preferred type:'; desc.style.margin = '0 0 10px 0'; desc.style.fontSize = '14px'; desc.style.color = '#ccc'; const select = document.createElement('select'); select.style.width = '100%'; select.style.margin = '6px 0 10px 0'; select.style.padding = '6px 10px'; select.style.backgroundColor = '#2b2b2b'; select.style.color = '#e6e6e6'; select.style.border = '1px solid #3d3d3d'; select.style.borderRadius = '4px'; const ordered = ['artist','group','parody','character','tag']; const present = ordered.filter(t => types.includes(t)); present.forEach(t => { const opt = document.createElement('option'); opt.value = t; opt.textContent = t; select.appendChild(opt); }); const rememberWrap = document.createElement('label'); rememberWrap.style.display = 'flex'; rememberWrap.style.alignItems = 'center'; rememberWrap.style.gap = '8px'; rememberWrap.style.color = '#ddd'; const remember = document.createElement('input'); remember.type = 'checkbox'; const rememberText = document.createElement('span'); rememberText.textContent = `Remember this choice for "${name}"`; rememberText.style.fontSize = '13px'; rememberWrap.appendChild(remember); rememberWrap.appendChild(rememberText); const actions = document.createElement('div'); actions.style.display = 'flex'; actions.style.gap = '8px'; actions.style.marginTop = '12px'; actions.style.justifyContent = 'flex-end'; const ok = document.createElement('button'); ok.textContent = 'Apply'; ok.className = 'btn-secondary'; ok.style.background = '#444'; ok.style.border = '1px solid #666'; ok.style.color = '#fff'; ok.style.padding = '8px 16px'; ok.style.borderRadius = '3px'; ok.addEventListener('mouseenter', () => { ok.style.background = '#555'; }); ok.addEventListener('mouseleave', () => { ok.style.background = '#444'; }); const cancel = document.createElement('button'); cancel.textContent = 'Skip'; cancel.className = 'btn-secondary'; cancel.style.background = '#444'; cancel.style.border = '1px solid #666'; cancel.style.color = '#fff'; cancel.style.padding = '8px 16px'; cancel.style.borderRadius = '3px'; cancel.addEventListener('mouseenter', () => { cancel.style.background = '#555'; }); cancel.addEventListener('mouseleave', () => { cancel.style.background = '#444'; }); ok.addEventListener('click', async () => { const chosen = select.value || 'tag'; if (remember.checked) { const ov = await GM.getValue('smartTagOverrides', {}); const norm = String(name).toLowerCase().replace(/-/g,' ').trim(); ov[norm] = chosen; await GM.setValue('smartTagOverrides', ov); } overlay.remove(); resolve(chosen); }); cancel.addEventListener('click', () => { overlay.remove(); resolve(null); }); overlay.addEventListener('click', (evt) => { if (evt.target === overlay) { overlay.remove(); resolve(null); } }); actions.appendChild(cancel); actions.appendChild(ok); box.appendChild(title); box.appendChild(desc); box.appendChild(select); box.appendChild(rememberWrap); box.appendChild(actions); overlay.appendChild(box); document.body.appendChild(overlay); }); } async function toAdvancedToken(tokenRaw) { const t = tokenRaw.trim(); if (!t) return null; if (isLanguage(t)) return `language:"${t.toLowerCase()}"`; const norm = t.toLowerCase().replace(/-/g,' ').trim(); if (typeof taxonomyManager !== 'undefined') { const overrides = await GM.getValue('smartTagOverrides', {}); const ovType = overrides[norm]; if (ovType) return `${ovType}:"${t}"`; const types = await taxonomyManager.getTypesForNameAsync(t); if (types.length === 1) return `${types[0]}:"${t}"`; if (types.length > 1) { const chosen = await promptForAmbiguity(t, types); if (chosen) return `${chosen}:"${t}"`; } } return null; } async function tokensFromGroup(groupContent) { // Prefer explicit separators if provided (comma, pipe, semicolon) const separated = groupContent.split(/[|,;]+/).map(s => s.trim()).filter(Boolean); if (separated.length > 1) { const mapped = []; for (const s of separated) { const adv = await toAdvancedToken(s); mapped.push(adv || s); } return mapped; } const words = groupContent.trim().split(/\s+/).filter(Boolean); // Heuristic: if words form pairs with repeated anchor (e.g., "mind control mind break") if (words.length >= 4 && words.length % 2 === 0) { const anchor = words[0].toLowerCase(); let pairs = true; for (let i = 0; i < words.length; i += 2) { if (i > 0 && words[i].toLowerCase() !== anchor) { pairs = false; break; } } if (pairs) { const phrases = []; for (let i = 0; i < words.length; i += 2) { phrases.push(`${words[i]} ${words[i+1]}`); } const mapped = []; for (const s of phrases) { const adv = await toAdvancedToken(s); if (adv) mapped.push(adv); } return mapped; } } // Fallback: treat each word as its own token const mapped = []; for (const wRaw of words) { if (likelyTitle) { mapped.push(wRaw); continue; } const w = wRaw.toLowerCase(); let adv; const isArtist = (typeof taxonomyManager !== 'undefined') ? await taxonomyManager.isArtistName(wRaw) : false; if (isArtist) { adv = `artist:"${wRaw}"`; } else { adv = await toAdvancedToken(wRaw); } mapped.push(adv || wRaw); } return mapped; } const advTokens = []; let i = 0; while (i < raw.length) { if (raw[i] === '(') { let j = i + 1, depth = 1; while (j < raw.length && depth > 0) { if (raw[j] === '(') depth++; else if (raw[j] === ')') depth--; j++; } const content = raw.slice(i + 1, j - 1); const mapped = await tokensFromGroup(content); advTokens.push(...mapped); i = j; } else { // accumulate plain segment until '(' and apply phrase dictionary let start = i; while (i < raw.length && raw[i] !== '(') i++; const segment = raw.slice(start, i); const words = segment.trim().split(/\s+/).filter(Boolean); // Use GitHub taxonomy exclusively const tm = (typeof taxonomyManager !== 'undefined') ? taxonomyManager : null; let k = 0; while (k < words.length) { let matched = false; // prefer longer phrases first for (let n = Math.min(3, words.length - k); n >= 2; n--) { const rawPhrase = words.slice(k, k + n).join(' '); const normPhrase = rawPhrase.toLowerCase().replace(/-/g,' ').trim(); const isArtistPhrase = tm ? await tm.isArtistName(rawPhrase) : false; if (isArtistPhrase) { advTokens.push(`artist:"${rawPhrase}"`); k += n; matched = true; break; } const types = tm ? await tm.getTypesForNameAsync(rawPhrase) : []; if (types && types.length > 0) { const adv = await toAdvancedToken(rawPhrase); advTokens.push(adv || rawPhrase); k += n; matched = true; break; } } if (!matched) { const wRaw = words[k]; if (typeof likelyTitle !== 'undefined' && likelyTitle) { advTokens.push(wRaw); k++; continue; } const isArtist = tm ? await tm.isArtistName(wRaw) : false; let adv; if (isArtist) { adv = `artist:"${wRaw}"`; } else { adv = await toAdvancedToken(wRaw); } advTokens.push(adv || wRaw); k++; } } } while (i < raw.length && /\s/.test(raw[i])) i++; } // Deduplicate across auto-advanced and must-add const seen = new Set(); const normalized = s => s.toLowerCase(); const uniqueAdvTokens = []; for (const adv of advTokens) { const key = normalized(adv); if (!seen.has(key)) { seen.add(key); uniqueAdvTokens.push(adv); } } if (mustAddTagsEnabled && mustAddTags.length > 0) { for (const t of mustAddTags) { const traw = String(t || '').trim(); if (!traw) continue; // skip empty/whitespace-only must-add entries let adv; const m = traw.match(/^([a-z]+):(.*)$/i); if (m) { const ns = m[1].toLowerCase(); const val = String(m[2] || '').trim().replace(/^\"(.*)\"$/, '$1').trim(); if (!val) continue; // avoid ns:"" tokens adv = `${ns}:"${val}"`; } else { adv = isLanguage(traw) ? `language:"${traw}"` : `tag:"${traw}"`; } const key = normalized(adv); if (!seen.has(key)) { seen.add(key); uniqueAdvTokens.push(adv); } } } finalQuery = uniqueAdvTokens.join(' '); } else { // Legacy behavior: merge plain tokens with Must Add Tags let queryArray = raw.split(/\s+/).filter(tag => tag.length > 0).map(tag => tag.toLowerCase()); if (mustAddTagsEnabled && mustAddTags.length > 0) { queryArray = [...new Set([...queryArray, ...mustAddTags])]; } finalQuery = queryArray.join(' '); } window.location.href = `/search/?q=${encodeURIComponent(finalQuery)}`; }); function deSmartTagQuery(q) { const text = String(q || '').trim(); if (!text) return ''; const tokens = text.match(/(?:[^\s"]+:"[^"]*"|[^\s]+)/g) || []; const out = []; for (const token of tokens) { const mQuoted = token.match(/^([a-z]+):"([^"]*)"$/i); if (mQuoted) { out.push(mQuoted[2]); continue; } const mBare = token.match(/^([a-z]+):(.+)$/i); if (mBare) { const v = String(mBare[2] || '').replace(/^"(.*)"$/, '$1'); out.push(v); continue; } out.push(String(token).replace(/^"(.*)"$/, '$1')); } return out.join(' ').replace(/\s+/g, ' ').trim(); } (async function smartTagNoResultsFallbackInit() { const smartTagEnabled = await GM.getValue('smartTagEnabled', true); if (!smartTagEnabled) return; if (!/\/search\/?$/i.test(window.location.pathname) && !/\/search\//i.test(window.location.pathname)) return; const h2 = document.querySelector('.container.index-container h2'); const title = (h2 && h2.textContent) ? h2.textContent.trim() : ''; if (!/no results found/i.test(title)) return; const q = new URLSearchParams(window.location.search).get('q') || ''; if (!/\w+:"/i.test(q)) return; const existing = document.getElementById('smarttag-noresults-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'smarttag-noresults-modal'; overlay.style.position = 'fixed'; overlay.style.inset = '0'; overlay.style.background = 'rgba(0, 0, 0, 0.8)'; overlay.style.zIndex = '9999'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; const box = document.createElement('div'); box.style.background = '#222'; box.style.color = '#fff'; box.style.border = '1px solid #444'; box.style.borderRadius = '6px'; box.style.boxShadow = '0 8px 24px rgba(0,0,0,0.45)'; box.style.maxWidth = '520px'; box.style.width = '92%'; box.style.padding = '16px'; box.style.fontFamily = 'Segoe UI, system-ui, -apple-system, Roboto, Arial'; const heading = document.createElement('h3'); heading.textContent = 'No results found'; heading.style.margin = '0 0 10px 0'; heading.style.color = '#fff'; const desc = document.createElement('p'); desc.textContent = 'Search again to perform a search without Smart Tags.'; desc.style.margin = '0 0 12px 0'; desc.style.fontSize = '14px'; desc.style.color = '#ccc'; const actions = document.createElement('div'); actions.style.display = 'flex'; actions.style.gap = '8px'; actions.style.marginTop = '12px'; actions.style.justifyContent = 'flex-end'; const cancel = document.createElement('button'); cancel.textContent = 'Dismiss'; cancel.className = 'btn-secondary'; cancel.style.background = '#444'; cancel.style.border = '1px solid #666'; cancel.style.color = '#fff'; cancel.style.padding = '8px 16px'; cancel.style.borderRadius = '3px'; cancel.addEventListener('mouseenter', () => { cancel.style.background = '#555'; }); cancel.addEventListener('mouseleave', () => { cancel.style.background = '#444'; }); const retry = document.createElement('button'); retry.textContent = 'Search Again (No Smart Tags)'; retry.className = 'btn-primary'; retry.style.background = '#e53935'; retry.style.border = '1px solid #ff6f60'; retry.style.color = '#fff'; retry.style.padding = '8px 16px'; retry.style.borderRadius = '3px'; retry.addEventListener('mouseenter', () => { retry.style.background = '#ff5252'; }); retry.addEventListener('mouseleave', () => { retry.style.background = '#e53935'; }); cancel.addEventListener('click', () => overlay.remove()); overlay.addEventListener('click', (evt) => { if (evt.target === overlay) overlay.remove(); }); retry.addEventListener('click', async () => { const form = document.querySelector('form.search'); const input = form ? form.querySelector('input[name="q"]') : null; const fallback = deSmartTagQuery(q); if (input) input.value = fallback; await GM.setValue('smartTagBypassOnce', true); overlay.remove(); if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } else { window.location.href = `/search/?q=${encodeURIComponent(fallback)}`; } }); actions.appendChild(cancel); actions.appendChild(retry); box.appendChild(heading); box.appendChild(desc); box.appendChild(actions); overlay.appendChild(box); document.body.appendChild(overlay); })(); (function removeErrorImageOnCustomPagesInit() { if (window.__nhPlusErrorImageRemovalInit) return; window.__nhPlusErrorImageRemovalInit = true; const selector = '#content > div > img.error-image.error-image-light'; function isTargetPage() { const path = String(window.location.pathname || ''); const hash = String(window.location.hash || ''); return path.includes('/continue_reading/') || path.includes('/settings/') || path.includes('/quick-nut/') || path.includes('/read-manga/') || path.includes('/bookmarks') || path.includes('/favorite') || hash === '#quick-nut' || hash === '#read-manga'; } function removeOnce() { if (!isTargetPage()) return; const img = document.querySelector(selector); if (img) img.remove(); } removeOnce(); const observer = new MutationObserver(() => removeOnce()); observer.observe(document.documentElement, { childList: true, subtree: true }); const originalPushState = history.pushState; history.pushState = function() { const ret = originalPushState.apply(this, arguments); setTimeout(removeOnce, 0); return ret; }; window.addEventListener('popstate', () => setTimeout(removeOnce, 0)); })(); // Modify XHR requests XMLHttpRequest.prototype.open = function(method, url) { // Store the original arguments and context for later use const xhr = this; const args = arguments; if (typeof url === 'string' && url.includes('/search/')) { try { const urlObj = new URL(url, window.location.origin); let query = urlObj.searchParams.get('q') || ''; const isAdvanced = /\w+:"/.test(query); if (isAdvanced) { // Skip Must-Add-Tags injection to avoid breaking advanced queries return originalOpen.apply(xhr, args); } let queryArray = query.split(/\s+/).filter(tag => tag.length > 0).map(tag => tag.toLowerCase()); // Fetch mustAddTags asynchronously for XHR as well GM.getValue('mustAddTagsEnabled', false).then(mustAddTagsEnabled => { if (mustAddTagsEnabled) { GM.getValue('mustAddTags', []).then(mustAddTagsRaw => { const mustAddTags = mustAddTagsRaw.map(tag => tag.toLowerCase()); try { queryArray = [...new Set([...queryArray, ...mustAddTags])]; query = queryArray.join(' '); urlObj.searchParams.set('q', query); // Re-open the request with the modified URL return originalOpen.apply(xhr, [method, urlObj.toString(), ...Array.prototype.slice.call(args, 2)]); } catch (error) { console.error('Error in XHR open override (mustAddTags):', error); // Fall back to original URL if there's an error return originalOpen.apply(xhr, args); } }).catch(error => { console.error('Error getting mustAddTags:', error); return originalOpen.apply(xhr, args); }); } else { return originalOpen.apply(xhr, args); } }).catch(error => { console.error('Error getting mustAddTagsEnabled:', error); return originalOpen.apply(xhr, args); }); } catch (error) { console.error('Error in XHR open override:', error); return originalOpen.apply(xhr, args); } } else { return originalOpen.apply(xhr, args); } }; // -----------------------------------------------**AutoSync Initialization**----------------------------------------------------------------------- // AutoSync initialization moved to be within scope of autoSyncManager definition // -----------------------------------------------**Must-Add-Tags**----------------------------------------------------------------------- // -----------------------------------------------**Manga-Sync**----------------------------------------------------------------------- // Online Data Sync Implementation class OnlineDataSync { constructor() { this.providers = { jsonstorage: new JSONStorageProvider() }; this.publicConfig = { // Proxied through Cloudflare Worker to protect credentials and allow storage migration // The old version (10.2.1) will continue to use the old hardcoded JSONStorage URL (and corrupt it), // while updated clients will use this proxy pointing to a new, clean storage bin. url: 'https://nhentai-share.babykoolstar.workers.dev/sync', apiKey: 'proxy' // Key is handled by the worker }; this.taxonomyConfig = { url: 'https://raw.githubusercontent.com/longkidkoolstar/Nhentai-Plus/refs/heads/main/nhentai_taxonomy.json', apiKey: '' }; } async getPublicUsersSnapshot() { const snap = await GM.getValue('publicUsersSnapshot', null); if (!snap || typeof snap !== 'object') return { users: [], at: null }; const users = Array.isArray(snap.users) ? snap.users.filter(u => typeof u === 'string') : []; const at = typeof snap.at === 'string' ? snap.at : null; return { users, at }; } async setPublicUsersSnapshot(users) { const list = Array.isArray(users) ? users.filter(u => typeof u === 'string') : []; await GM.setValue('publicUsersSnapshot', { users: list, at: new Date().toISOString() }); } // Generate 5-character alphanumeric UUID generateUUID() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let result = ''; for (let i = 0; i < 5; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } // Get or create user UUID async getUserUUID() { // Check if we have a cached UUID first if (this.cachedUUID) { return this.cachedUUID; } let uuid = await GM.getValue('userUUID'); if (!uuid) { uuid = this.generateUUID(); await GM.setValue('userUUID', uuid); } // Cache the UUID for future use this.cachedUUID = uuid; return uuid; } // Collect essential syncable data (reduced for bandwidth efficiency) async collectSyncData() { const allKeys = await GM.listValues(); const syncData = { version: CURRENT_VERSION, timestamp: new Date().toISOString(), userUUID: await this.getUserUUID(), data: {} }; // Define which keys to sync const syncableKeys = [ 'bookmarkedPages', 'offlineFavorites', 'mustAddTags', 'mustAddTagsEnabled', 'randomPrefLanguage', 'randomPrefTags', 'randomPrefPagesMin', 'randomPrefPagesMax', 'blacklistedTags', 'findSimilarEnabled', 'bookmarksEnabled', 'maxTagsToSelect', 'showNonEnglish', 'showPageNumbersEnabled', 'maxMangaPerBookmark', 'englishFilterEnabled', 'autoLoginEnabled','findAltmangaEnabled', 'bookmarksEnabled', 'language', 'tags', 'pagesMin', 'pagesMax', 'matchAllTags', 'mustAddTagsEnabled', 'findAltMangaThumbnailEnabled', 'openInNewTabEnabled', 'mangaBookMarkingButtonEnabled', 'mangaBookMarkingType', 'bookmarkArrangementType', 'monthFilterEnabled', 'tooltipsEnabled', 'mangagroupingenabled', 'maxMangaPerBookmark', 'openInNewTabType', 'offlineFavoritingEnabled', 'offlineFavoritesPageEnabled', 'nfmPageEnabled', 'publicSyncEnabled', 'privateSyncEnabled', 'autoSyncEnabled', 'syncInterval', 'lastSyncUpload', 'lastSyncDownload', 'bookmarksPageEnabled', 'replaceRelatedWithBookmarks', 'enableRelatedFlipButton', 'twitterButtonEnabled', 'enableRandomButton', 'randomOpenType', 'profileButtonEnabled', 'infoButtonEnabled', 'logoutButtonEnabled', 'bookmarkLinkEnabled', 'findSimilarType', 'bookmarkedMangas', // New Hide/Blacklist feature 'hideBlacklistEnabled', 'hiddenGalleries', // Inbox & Share settings 'shareButtonEnabled', 'receiveSharesEnabled', 'receivePopupsEnabled', 'inboxPollIntervalMin', 'inboxMessages', 'favoriteTagsList' ]; for (const key of syncableKeys) { if (allKeys.includes(key)) { syncData.data[key] = await GM.getValue(key); } } return syncData; } // Apply synced data async applySyncData(syncData) { if (!syncData || !syncData.data) { throw new Error('Invalid sync data format'); } const currentUUID = await this.getUserUUID(); if (syncData.userUUID && syncData.userUUID !== currentUUID) { const confirmMerge = confirm( `This data belongs to a different user (${syncData.userUUID}). ` + `Your UUID is ${currentUUID}. Do you want to merge this data anyway?` ); if (!confirmMerge) { throw new Error('User cancelled data merge'); } } let appliedCount = 0; for (const [key, value] of Object.entries(syncData.data)) { await GM.setValue(key, value); appliedCount++; } await GM.setValue('lastSyncDownload', new Date().toISOString()); return appliedCount; } // Upload data using specified provider (supports multiple users) async uploadData(providerType, config) { const provider = this.providers[providerType]; if (!provider) { throw new Error(`Unknown provider: ${providerType}`); } const userSyncData = await this.collectSyncData(); const userUUID = userSyncData.userUUID; // Download existing data to merge with current user's data const isPublicSyncTarget = !!(config && config.url && config.url === this.publicConfig.url); let existingData = {}; try { existingData = await provider.download(config); } catch (error) { if (isPublicSyncTarget) { throw new Error(`Public sync safety: aborting upload because existing cloud data could not be downloaded (${error && error.message ? error.message : String(error)})`); } console.log('No existing data found, creating new storage'); } if (existingData === null || (typeof existingData !== 'object') || Array.isArray(existingData)) { if (isPublicSyncTarget) { throw new Error('Public sync safety: aborting upload because existing cloud data format is invalid'); } existingData = {}; } if (!existingData.users && existingData.userUUID && existingData.data) { existingData = { users: { [existingData.userUUID]: existingData } }; } // Ensure existingData has the correct structure for multiple users if (!existingData.users) { if (isPublicSyncTarget) { throw new Error('Public sync safety: aborting upload because existing cloud data has no users map'); } existingData = { // version: CURRENT_VERSION, //lastUpdated: new Date().toISOString(), users: {} }; } if (typeof existingData.users !== 'object' || existingData.users === null || Array.isArray(existingData.users)) { if (isPublicSyncTarget) { throw new Error('Public sync safety: aborting upload because existing cloud users map is invalid'); } existingData.users = {}; } if (isPublicSyncTarget) { const existingUsers = Object.keys(existingData.users); const snapshot = await this.getPublicUsersSnapshot(); if (snapshot.users.length) { const existingSet = new Set(existingUsers); const missingCount = snapshot.users.reduce((acc, u) => acc + (existingSet.has(u) ? 0 : 1), 0); const shrunk = existingUsers.length < snapshot.users.length; const missingTooMany = missingCount >= 3 || (snapshot.users.length >= 10 && missingCount >= Math.ceil(snapshot.users.length * 0.25)); if (shrunk && missingTooMany) { throw new Error('Public sync safety: aborting upload because public storage appears to be missing many users compared to your last known snapshot'); } } await this.setPublicUsersSnapshot(existingUsers); } // Add/update current user's data existingData.users[userUUID] = userSyncData; // existingData.lastUpdated = new Date().toISOString(); // existingData.version = CURRENT_VERSION; await provider.upload(config, existingData); await GM.setValue('lastSyncUpload', new Date().toISOString()); return userSyncData; } // Download data using specified provider (supports multiple users) async downloadData(providerType, config) { const provider = this.providers[providerType]; if (!provider) { throw new Error(`Unknown provider: ${providerType}`); } const allData = await provider.download(config); const isPublicSyncTarget = !!(config && config.url && config.url === this.publicConfig.url); if (isPublicSyncTarget && allData && typeof allData === 'object' && allData.users && typeof allData.users === 'object' && !Array.isArray(allData.users)) { await this.setPublicUsersSnapshot(Object.keys(allData.users)); } const userUUID = await this.getUserUUID(); // Handle both old single-user format and new multi-user format let userSyncData; if (allData.users && allData.users[userUUID]) { // New multi-user format userSyncData = allData.users[userUUID]; } else if (allData.userUUID === userUUID) { // Old single-user format userSyncData = allData; } else if (allData.users) { // Multi-user format but user not found const availableUsers = Object.keys(allData.users); throw new Error(`No data found for UUID ${userUUID}. Available UUIDs: ${availableUsers.join(', ')}`); } else { // Single-user format but different user throw new Error(`Data belongs to UUID ${allData.userUUID}, but your UUID is ${userUUID}`); } const appliedCount = await this.applySyncData(userSyncData); return { syncData: userSyncData, appliedCount, allUsers: allData.users ? Object.keys(allData.users) : [allData.userUUID] }; } // Get available users from cloud storage without downloading data async getAvailableUsers(providerType, config) { const provider = this.providers[providerType]; if (!provider) { throw new Error(`Unknown provider: ${providerType}`); } const allData = await provider.download(config); const isPublicSyncTarget = !!(config && config.url && config.url === this.publicConfig.url); if (isPublicSyncTarget && allData && typeof allData === 'object' && allData.users && typeof allData.users === 'object' && !Array.isArray(allData.users)) { await this.setPublicUsersSnapshot(Object.keys(allData.users)); } if (allData.users) { // Multi-user format return Object.keys(allData.users).map(uuid => ({ uuid, version: allData.users[uuid].version || 'Unknown', timestamp: allData.users[uuid].timestamp, dataCount: Object.keys(allData.users[uuid].data || {}).length })); } else if (allData.userUUID) { // Old single-user format return [{ uuid: allData.userUUID, version: allData.version || 'Unknown', timestamp: allData.timestamp, dataCount: Object.keys(allData.data || {}).length }]; } return []; } } // Taxonomy Manager Implementation class TaxonomyManager { constructor(syncSystem) { this.syncSystem = syncSystem; this.byType = { artist: new Set(), tag: new Set(), parody: new Set(), group: new Set(), character: new Set() }; this.lastLoadedAt = null; this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); // Mobile on-demand cache (short-lived, avoids holding full taxonomy long-term) this.mobileByTypeCache = null; // { artist: string[], group: string[], parody: string[], character: string[], tag: string[] } this.mobileByTypeCacheAt = 0; this.mobileByTypeCacheTTL = 60000; // 60s TTL to minimize memory footprint } normalizeName(name) { return String(name || '').toLowerCase().replace(/-/g, ' ').trim(); } applyCompressed(base64) { try { const json = LZString.decompressFromBase64(base64); const payload = JSON.parse(json); const byType = { artist: new Set(), tag: new Set(), parody: new Set(), group: new Set(), character: new Set() }; if (payload && Array.isArray(payload.items)) { for (const it of payload.items) { const type = String(it.type || '').toLowerCase(); const name = this.normalizeName(it.name); if (name && byType[type]) byType[type].add(name); } } else if (payload && payload.byType && typeof payload.byType === 'object') { for (const [type, arr] of Object.entries(payload.byType)) { const t = String(type || '').toLowerCase(); const names = Array.isArray(arr) ? arr : []; if (!byType[t]) continue; for (const n of names) { const name = this.normalizeName(n); if (name) byType[t].add(name); } } } this.byType = byType; this.lastLoadedAt = Date.now(); } catch (e) { console.warn('TaxonomyManager: decompress/apply failed', e); } } async loadFromStorage() { try { const compressed = await GM.getValue('taxonomyCompressed', null); if (compressed) { this.applyCompressed(compressed); return true; } } catch (e) { console.warn('TaxonomyManager: loadFromStorage failed', e); } return false; } async ensureLoaded() { if (this.isMobile) return true; if (this.lastLoadedAt) return true; await this.fetchIfStale(); return !!this.lastLoadedAt; } async fetchIfStale() { try { const provider = this.syncSystem.providers.jsonstorage; const config = this.syncSystem.taxonomyConfig || this.syncSystem.publicConfig; const data = await provider.download(config); // Accept either top-level byType or taxonomy.byType let byTypeObj = null; if (data && data.byType) { byTypeObj = data.byType; } else if (data && data.taxonomy && data.taxonomy.byType) { byTypeObj = data.taxonomy.byType; } if (byTypeObj && typeof byTypeObj === 'object') { const byType = { artist: new Set(), tag: new Set(), parody: new Set(), group: new Set(), character: new Set() }; for (const [type, arr] of Object.entries(byTypeObj)) { const t = String(type || '').toLowerCase(); const names = Array.isArray(arr) ? arr : []; if (!byType[t]) continue; for (const n of names) { const name = this.normalizeName(n); if (name) byType[t].add(name); } } this.byType = byType; this.lastLoadedAt = Date.now(); } else { console.warn('TaxonomyManager: no byType data in fetched payload'); } } catch (e) { console.warn('TaxonomyManager: fetch failed', e); } } /* removed old caching logic for taxonomy; now fetches directly from GitHub and applies in-memory */ async getTypesForNameAsync(name) { const n = this.normalizeName(name); if (!n) return []; if (!this.isMobile) { return this.getTypesForName(name); } try { const provider = this.syncSystem.providers.jsonstorage; const config = this.syncSystem.taxonomyConfig || this.syncSystem.publicConfig; let byTypeObj = null; const now = Date.now(); // Use short-lived cache if fresh if (this.mobileByTypeCache && (now - this.mobileByTypeCacheAt) < this.mobileByTypeCacheTTL) { byTypeObj = this.mobileByTypeCache; } else { const data = await provider.download(config); if (data && typeof data === 'object') { if (data.byType && typeof data.byType === 'object') { byTypeObj = data.byType; } else if (data.taxonomy && typeof data.taxonomy === 'object') { const tax = data.taxonomy; if (tax.isCompressed && tax.compressedData) { try { const json = LZString.decompressFromBase64(tax.compressedData); const payload = JSON.parse(json); if (payload && payload.byType && typeof payload.byType === 'object') { byTypeObj = payload.byType; } } catch (e) { console.warn('TaxonomyManager: decompress taxonomy failed', e); } } else if (tax.byType && typeof tax.byType === 'object') { byTypeObj = tax.byType; } } } // Store short-lived cache to reduce repeated downloads within a minute if (byTypeObj) { this.mobileByTypeCache = byTypeObj; this.mobileByTypeCacheAt = now; } } if (!byTypeObj) return []; const types = []; const test = (arr) => Array.isArray(arr) && arr.some(s => this.normalizeName(s) === n); if (test(byTypeObj.artist)) types.push('artist'); if (test(byTypeObj.group)) types.push('group'); if (test(byTypeObj.parody)) types.push('parody'); if (test(byTypeObj.character)) types.push('character'); if (test(byTypeObj.tag)) types.push('tag'); return types; } catch (_) { return []; } } getTypesForName(name) { const n = this.normalizeName(name); if (!n) return []; const types = []; if (this.byType.artist.has(n)) types.push('artist'); if (this.byType.group.has(n)) types.push('group'); if (this.byType.parody.has(n)) types.push('parody'); if (this.byType.character.has(n)) types.push('character'); if (this.byType.tag.has(n)) types.push('tag'); return types; } async getTypeForNameAsync(name) { const types = await this.getTypesForNameAsync(name); if (types.includes('artist')) return 'artist'; if (types.includes('group')) return 'group'; if (types.includes('parody')) return 'parody'; if (types.includes('character')) return 'character'; if (types.includes('tag')) return 'tag'; return null; } getTypeForName(name) { const n = this.normalizeName(name); if (!n) return null; // Prefer more specific namespaces first if (this.byType.artist.has(n)) return 'artist'; if (this.byType.group.has(n)) return 'group'; if (this.byType.parody.has(n)) return 'parody'; if (this.byType.character.has(n)) return 'character'; if (this.byType.tag.has(n)) return 'tag'; return null; } async isArtistName(name) { const type = await this.getTypeForNameAsync(name); return type === 'artist'; } unload() { try { this.byType = { artist: new Set(), tag: new Set(), parody: new Set(), group: new Set(), character: new Set() }; this.lastLoadedAt = null; this.mobileByTypeCache = null; this.mobileByTypeCacheAt = 0; } catch (_) {} } } function uint8ArrayToBase64(bytes) { let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); } return btoa(binary); } function base64ToUint8Array(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } function supportsGzipStreams() { return typeof CompressionStream === 'function' && typeof DecompressionStream === 'function'; } async function gzipCompressStringToBase64(text) { const cs = new CompressionStream('gzip'); const stream = new Blob([text]).stream().pipeThrough(cs); const buffer = await new Response(stream).arrayBuffer(); return uint8ArrayToBase64(new Uint8Array(buffer)); } async function gzipDecompressBase64ToString(base64) { const bytes = base64ToUint8Array(base64); const ds = new DecompressionStream('gzip'); const stream = new Blob([bytes]).stream().pipeThrough(ds); return await new Response(stream).text(); } // JSONStorage.net provider implementation class JSONStorageProvider { async compressUserData(userData) { // Return as-is if null/undefined if (!userData) return userData; try { // Prevent double compression if data is already compressed if (userData.isCompressed && userData.compressedData) { return userData; } const preservedVersion = userData.version || CURRENT_VERSION; const json = JSON.stringify(userData); if (supportsGzipStreams()) { try { const compressedData = await gzipCompressStringToBase64(json); return { isCompressed: true, algo: 'gzip', version: preservedVersion, compressedData }; } catch (_) {} } return { isCompressed: true, algo: 'lz-string', version: preservedVersion, compressedData: LZString.compressToBase64(json) }; } catch (error) { console.error('User data compression failed, using uncompressed data:', error); return userData; // Fallback to uncompressed data } } // Decompress individual user data if it's compressed async decompressUserData(userData) { try { if (userData && userData.isCompressed && userData.compressedData) { const algo = (userData.algo || '').toLowerCase(); let json = null; if (algo === 'gzip' && supportsGzipStreams()) { json = await gzipDecompressBase64ToString(userData.compressedData); } else { json = LZString.decompressFromBase64(userData.compressedData); } if (json === null) { throw new Error(`Decompression failed (algo: ${algo || 'lz-string'})`); } const decompressed = JSON.parse(json); if (decompressed === null) { throw new Error('Decompressed data resulted in null'); } return decompressed; } return userData; // Return as is if not compressed } catch (error) { console.error('User data decompression failed:', error); return userData; // Return original data if decompression fails } } // Process data for upload - compresses each user's data individually async prepareDataForUpload(data) { // If data doesn't have users structure, return as is if (!data || !data.users) return data; // Create a copy of the data structure const processedData = { ...data, users: {} }; // Compress each user's data individually for (const [uuid, userData] of Object.entries(data.users)) { processedData.users[uuid] = await this.compressUserData(userData); } return processedData; } // Process downloaded data - decompresses each user's data individually async processDownloadedData(data) { // If data doesn't have users structure, return as is if (!data || !data.users) return data; // Create a copy of the data structure const processedData = { ...data, users: {} }; // Decompress each user's data individually for (const [uuid, userData] of Object.entries(data.users)) { processedData.users[uuid] = await this.decompressUserData(userData); } return processedData; } async upload(config, data) { const processedData = await this.prepareDataForUpload(data); return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'PUT', url: `${config.url}?apiKey=${config.apiKey}`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(processedData), onload: function(response) { if (response.status === 200) { resolve(JSON.parse(response.responseText)); } else { reject(new Error(`Upload failed: ${response.status} ${response.statusText}`)); } }, onerror: function(error) { reject(new Error(`Network error: ${error}`)); } }); }); } async download(config) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'GET', url: `${config.url}?apiKey=${config.apiKey}`, headers: { 'Accept': 'application/json' }, onload: async (response) => { if (response.status === 200) { const data = JSON.parse(response.responseText); // Process downloaded data (decompress each user's data individually) const processedData = await this.processDownloadedData(data); resolve(processedData); } else { reject(new Error(`Download failed: ${response.status} ${response.statusText}`)); } }, onerror: function(error) { reject(new Error(`Network error: ${error}`)); } }); }); } } // AutoSync Manager Class // Handles automatic syncing of user data at specified intervals // Features: // - Configurable sync intervals (5-1440 minutes) // - Automatic retry with exponential backoff on errors // - Support for both public and private sync endpoints // - Manual trigger capability // - Data change detection for immediate sync class AutoSyncManager { constructor(syncSystem) { this.syncSystem = syncSystem; this.intervalId = null; this.isEnabled = false; this.intervalMinutes = 30; this.lastSyncAttempt = null; this.consecutiveErrors = 0; this.maxRetries = 3; } // Initialize autosync based on current settings async initialize() { const autoSyncEnabled = await GM.getValue('autoSyncEnabled', false); const syncInterval = await GM.getValue('syncInterval', 30); this.isEnabled = autoSyncEnabled; this.intervalMinutes = syncInterval; if (this.isEnabled) { this.start(); console.log(`AutoSync initialized: enabled=${this.isEnabled}, interval=${this.intervalMinutes} minutes`); } else { console.log('AutoSync disabled'); } } // Start the autosync timer start() { this.stop(); // Clear any existing timer if (!this.isEnabled) { console.log('AutoSync not enabled, skipping start'); return; } console.log(`AutoSync started with ${this.intervalMinutes} minute interval`); // Check if sync is needed on script load this.checkAndPerformSync(); } // Stop the autosync timer stop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; console.log('AutoSync stopped'); } } // Check if sync is needed based on time elapsed since last sync async checkAndPerformSync() { if (!this.isEnabled) { return; } const lastAutoSync = await GM.getValue('lastAutoSync', null); const now = new Date().getTime(); const intervalMs = this.intervalMinutes * 60 * 1000; // Convert minutes to milliseconds // If no previous sync or enough time has passed, perform sync if (!lastAutoSync || (now - new Date(lastAutoSync).getTime()) >= intervalMs) { console.log('AutoSync: Time interval reached, performing sync...'); await this.performAutoSync(); } else { const timeUntilNextSync = intervalMs - (now - new Date(lastAutoSync).getTime()); const minutesUntilNext = Math.round(timeUntilNextSync / 60000); console.log(`AutoSync: Next sync in approximately ${minutesUntilNext} minutes`); } } // Update settings and restart if needed async updateSettings(enabled, intervalMinutes) { const wasEnabled = this.isEnabled; const oldInterval = this.intervalMinutes; this.isEnabled = enabled; this.intervalMinutes = intervalMinutes; // Restart if settings changed if (enabled && (!wasEnabled || oldInterval !== intervalMinutes)) { this.start(); } else if (!enabled && wasEnabled) { this.stop(); } console.log(`AutoSync settings updated: enabled=${enabled}, interval=${intervalMinutes} minutes`); } // Perform the actual sync operation async performAutoSync() { if (!this.isEnabled) { return; } this.lastSyncAttempt = new Date().toISOString(); console.log('AutoSync: Starting automatic sync...'); try { // Determine which sync method to use based on user preferences const publicSyncEnabled = await GM.getValue('publicSyncEnabled', false); const privateSyncEnabled = await GM.getValue('privateSyncEnabled', false); let syncPerformed = false; // Try private sync first if enabled if (privateSyncEnabled) { const privateStorageUrl = await GM.getValue('privateStorageUrl', ''); const privateApiKey = await GM.getValue('privateApiKey', ''); if (privateStorageUrl && privateApiKey) { await this.syncSystem.uploadData('jsonstorage', { url: privateStorageUrl, apiKey: privateApiKey }); console.log('AutoSync: Private sync completed successfully'); syncPerformed = true; } } // Fall back to public sync if private sync wasn't performed and public is enabled if (!syncPerformed && publicSyncEnabled) { await this.syncSystem.uploadData('jsonstorage', this.syncSystem.publicConfig); console.log('AutoSync: Public sync completed successfully'); syncPerformed = true; } if (syncPerformed) { this.consecutiveErrors = 0; // Reset error counter on success await GM.setValue('lastAutoSync', new Date().toISOString()); } else { console.log('AutoSync: No sync method enabled, skipping'); } } catch (error) { this.consecutiveErrors++; console.error(`AutoSync error (attempt ${this.consecutiveErrors}):`, error); // If we've had too many consecutive errors, temporarily disable autosync if (this.consecutiveErrors >= this.maxRetries) { console.warn(`AutoSync: Too many consecutive errors (${this.consecutiveErrors}), temporarily disabling for this session`); this.stop(); // Show a notification to the user if they're on the page if (typeof showPopup === 'function') { showPopup(`AutoSync temporarily disabled due to repeated errors: ${error.message}`, { timeout: 10000 }); } } } } // Get status information async getStatus() { const lastAutoSync = await GM.getValue('lastAutoSync', null); return { enabled: this.isEnabled, intervalMinutes: this.intervalMinutes, lastSyncAttempt: this.lastSyncAttempt, lastSuccessfulSync: lastAutoSync, consecutiveErrors: this.consecutiveErrors, isRunning: this.intervalId !== null }; } // Trigger immediate sync if autosync is enabled and data has changed async triggerDataChangeSync(changedKey) { if (!this.isEnabled) { return; } // Define which keys should trigger immediate sync const syncableKeys = [ 'bookmarkedPages', 'offlineFavorites', 'mustAddTags', 'mustAddTagsEnabled', 'randomPrefLanguage', 'randomPrefTags', 'randomPrefPagesMin', 'randomPrefPagesMax', 'blacklistedTags', 'findSimilarEnabled', 'bookmarksEnabled', 'maxTagsToSelect', 'showNonEnglish', 'showPageNumbersEnabled', 'maxMangaPerBookmark' ]; if (syncableKeys.includes(changedKey)) { console.log(`AutoSync: Data change detected for ${changedKey}, triggering immediate sync`); // Debounce rapid changes - only sync if last sync was more than 30 seconds ago const lastSync = await GM.getValue('lastAutoSync', null); const now = new Date().getTime(); const thirtySecondsAgo = now - 30000; if (!lastSync || new Date(lastSync).getTime() < thirtySecondsAgo) { console.log('AutoSync: Performing immediate sync due to data change'); await this.performAutoSync(); } else { console.log('AutoSync: Skipping immediate sync due to recent sync activity'); } } } } // Initialize sync system const syncSystem = new OnlineDataSync(); const autoSyncManager = new AutoSyncManager(syncSystem); const taxonomyManager = new TaxonomyManager(syncSystem); // Helper function to save data and trigger autosync async function setValueWithAutoSync(key, value) { await GM.setValue(key, value); await autoSyncManager.triggerDataChangeSync(key); } // Kick off inbox polling for received shares (if enabled) startShareInboxPoller(); //------------------------ **Mark as Read System** ------------------ /** * Mark as Read System - Enhanced version with configurable opacity and auto-mark functionality */ class MarkAsReadSystem { constructor() { this.readGalleries = new Set(); this.settings = { enabled: true, autoMarkEnabled: true, readOpacity: 0.6, nonEnglishOpacity: 0.2 }; this.init(); } /** * Initialize the Mark as Read system */ async init() { await this.loadSettings(); await this.loadReadGalleries(); if (this.settings.enabled) { this.addCSS(); this.addMarkAsReadButtons(); this.applyReadStatus(); this.setupAutoMark(); await this.checkAndApplyJustRead(); // New: Check and apply justRead data } } /** * Load settings from GM storage */ async loadSettings() { this.settings.enabled = await GM.getValue('markAsReadEnabled', true); this.settings.autoMarkEnabled = await GM.getValue('autoMarkReadEnabled', true); this.settings.readOpacity = await GM.getValue('readGalleriesOpacity', 0.6); this.settings.nonEnglishOpacity = await GM.getValue('nonEnglishOpacity', 0.2); } /** * Load read galleries from localStorage */ async loadReadGalleries() { try { const readList = await GM.getValue('readGalleries', []); this.readGalleries = new Set(readList); } catch (error) { console.error('Error loading read galleries:', error); this.readGalleries = new Set(); } } /** * Save read galleries to localStorage */ async saveReadGalleries() { try { await GM.setValue('readGalleries', Array.from(this.readGalleries)); } catch (error) { console.error('Error saving read galleries:', error); } } /** * Check for 'justRead' key in localStorage, apply, and remove it. */ async checkAndApplyJustRead() { const parseGalleries = (raw) => { try { const data = JSON.parse(raw); return Array.isArray(data) ? data : null; } catch (err) { console.error('Error parsing justRead data from localStorage:', err); return null; } }; const isPlaceholderMeta = (g) => { if (!g) return true; const cover = String(g.coverImageUrl || ''); const language = String(g.language || ''); const title = String(g.title || ''); return cover.startsWith('data:image/svg') && language === 'Unknown' && title === 'Unknown'; }; const justReadData = localStorage.getItem('justRead'); if (!justReadData) return; let galleries = parseGalleries(justReadData); if (!galleries) { // Invalid JSON; clean up to avoid repeated errors localStorage.removeItem('justRead'); return; } // If any gallery has placeholder metadata, wait for external script to populate if (galleries.some(isPlaceholderMeta)) { // Inform the user we're fetching missing metadata this.showMarkNotification('Fetching metadata to mark as read...', 'info'); const intervalMs = 2000; // check every 2 seconds // Keep checking until metadata is populated; do not delete justRead early while (true) { await new Promise(r => setTimeout(r, intervalMs)); const updatedRaw = localStorage.getItem('justRead'); if (!updatedRaw) { // External script removed it; stop here return; } const updated = parseGalleries(updatedRaw); if (!updated) { // If parsing fails, keep waiting continue; } if (updated.every(g => !isPlaceholderMeta(g))) { galleries = updated; break; } } } // Process and mark as read let markedCount = 0; for (const gallery of galleries) { if (gallery && gallery.id && !this.readGalleries.has(gallery.id)) { this.readGalleries.add(gallery.id); await this.cacheGalleryData(gallery.id, gallery.title, gallery.coverImageUrl); markedCount++; } } if (markedCount > 0) { await this.saveReadGalleries(); this.applyReadStatus(); // Re-apply styles to reflect new reads this.showAutoMarkNotification(markedCount); } // Remove once processed successfully localStorage.removeItem('justRead'); } /** * Show a notification for auto-marked galleries. */ showAutoMarkNotification(count) { // Placeholder for notification logic. Implement as needed. console.log(`Auto-marked ${count} galleries as read from 'justRead' data.`); // Example: You might want to use a more visible notification system here. // For instance, a temporary div that fades out, similar to the changelog popup. } /** * Extract gallery ID from URL or element */ extractGalleryId(url) { if (!url) return null; const match = url.match(/\/g\/(\d+)\//); return match ? match[1] : null; } /** * Check if a gallery is marked as read */ isRead(galleryId) { return this.readGalleries.has(galleryId); } /** * Mark a gallery as read */ async markAsRead(galleryId) { if (!galleryId) return; this.readGalleries.add(galleryId); await this.saveReadGalleries(); // Cache gallery data for Read Manga page await this.cacheGalleryData(galleryId); this.updateGalleryVisuals(galleryId); } /** * Unmark a gallery as read */ async unmarkAsRead(galleryId) { if (!galleryId) return; this.readGalleries.delete(galleryId); await this.saveReadGalleries(); this.updateGalleryVisuals(galleryId); } /** * Toggle read status of a gallery */ async toggleReadStatus(galleryId) { if (this.isRead(galleryId)) { await this.unmarkAsRead(galleryId); } else { await this.markAsRead(galleryId); } } /** * Add CSS styles for the mark as read system */ addCSS() { const css = ` /* Mark as Read Button Styles */ .mark-as-read-btn { position: absolute; top: 5px; right: 5px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.7); border: none; border-radius: 50%; color: #fff; cursor: pointer; font-size: 12px; z-index: 10; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; } .mark-as-read-btn:hover { background: rgba(0, 0, 0, 0.9); transform: scale(1.1); } .mark-as-read-btn.read { background: rgba(46, 125, 50, 0.8); color: #fff; } .mark-as-read-btn.read:hover { background: rgba(46, 125, 50, 1); } /* Read Gallery Badge */ .read-badge { position: absolute; top: 5px; left: 5px; background: rgba(46, 125, 50, 0.9); color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; z-index: 10; pointer-events: none; } /* Gallery container positioning */ .gallery { position: relative; } /* Read gallery opacity */ .gallery.read-gallery .cover img, .gallery.read-gallery .cover .caption { opacity: var(--read-opacity, 0.6); transition: opacity 0.3s ease; } .gallery.read-gallery:hover .cover img, .gallery.read-gallery:hover .cover .caption { opacity: 1; } /* Animation for marking as read */ @keyframes markAsReadAnimation { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } .mark-as-read-animation { animation: markAsReadAnimation 0.3s ease; } /* Custom Mark as Read Button for Gallery Pages */ .btn-nhi-mark-as-read { display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 6px; text-decoration: none; font-weight: 500; transition: all 0.3s ease; border: 1px solid transparent; cursor: pointer; user-select: none; } .btn-nhi-mark-as-read:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); } .btn-nhi-mark-as-read:active { transform: translateY(0); } .btn-nhi-mark-as-read svg { transition: transform 0.3s ease; } .btn-nhi-mark-as-read:hover svg { transform: scale(1.1); } /* Success state styling */ .btn-nhi-mark-as-read.btn-success { color: white; border-color: rgba(255, 255, 255, 0.2); } .btn-nhi-mark-as-read.btn-success:hover { background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%) !important; color: white; } `; GM.addStyle(css); // Set CSS custom property for read opacity document.documentElement.style.setProperty('--read-opacity', this.settings.readOpacity); } /** * Add mark as read buttons to gallery thumbnails */ addMarkAsReadButtons() { // Don't add mark-as-read buttons on the read-manga page if (window.location.pathname === '/read-manga/' || window.location.hash === '#read-manga' || document.body.classList.contains('read-manga-active') || document.querySelector('.read-manga-page')) { return; } const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { // Skip if button already exists if (gallery.querySelector('.mark-as-read-btn')) return; // Skip if this is a read-manga-gallery (from read manga page) if (gallery.classList.contains('read-manga-gallery')) return; const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const galleryId = this.extractGalleryId(coverLink.getAttribute('href')); if (!galleryId) return; const button = document.createElement('button'); button.className = 'mark-as-read-btn'; button.title = 'Mark as Read'; button.innerHTML = this.isRead(galleryId) ? '✓' : '○'; if (this.isRead(galleryId)) { button.classList.add('read'); } button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); button.classList.add('mark-as-read-animation'); await this.toggleReadStatus(galleryId); setTimeout(() => { button.classList.remove('mark-as-read-animation'); }, 300); }); gallery.appendChild(button); }); } /** * Apply read status visual effects to galleries */ applyReadStatus() { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const galleryId = this.extractGalleryId(coverLink.getAttribute('href')); if (!galleryId) return; if (this.isRead(galleryId)) { gallery.classList.add('read-gallery'); this.addReadBadge(gallery); } else { gallery.classList.remove('read-gallery'); this.removeReadBadge(gallery); } }); } /** * Add read badge to a gallery */ addReadBadge(gallery) { if (gallery.querySelector('.read-badge')) return; const badge = document.createElement('div'); badge.className = 'read-badge'; badge.textContent = 'READ'; gallery.appendChild(badge); } /** * Remove read badge from a gallery */ removeReadBadge(gallery) { const badge = gallery.querySelector('.read-badge'); if (badge) { badge.remove(); } } /** * Update visual state of a specific gallery */ updateGalleryVisuals(galleryId) { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const currentId = this.extractGalleryId(coverLink.getAttribute('href')); if (currentId !== galleryId) return; const button = gallery.querySelector('.mark-as-read-btn'); const isRead = this.isRead(galleryId); if (button) { button.innerHTML = isRead ? '✓' : '○'; button.title = isRead ? 'Mark as Unread' : 'Mark as Read'; button.classList.toggle('read', isRead); } gallery.classList.toggle('read-gallery', isRead); if (isRead) { this.addReadBadge(gallery); } else { this.removeReadBadge(gallery); } }); } /** * Setup auto-mark functionality for individual gallery pages */ setupAutoMark() { if (!this.settings.autoMarkEnabled) return; // Check if we're on a gallery page const galleryMatch = window.location.pathname.match(/\/g\/(\d+)\//); if (!galleryMatch) return; const galleryId = galleryMatch[1]; // Add custom mark-as-read button to gallery page this.addGalleryPageMarkButton(galleryId); // Check if we're on the last page of the gallery this.checkLastPage(galleryId); } /** * Add custom mark-as-read button to individual gallery pages */ addGalleryPageMarkButton(galleryId) { // Check if button already exists if (document.getElementById('nhi-mar-button')) return; // Find the specific .buttons container const buttonsContainer = document.querySelector('.buttons'); if (!buttonsContainer) { console.warn('Buttons container not found, mark-as-read button will not be added'); return; } // Create the custom mark-as-read button to match the existing button style const isRead = this.isRead(galleryId); const buttonHTML = ` <a href="#" id="nhi-mar-button" class="btn ${isRead ? 'btn-success' : 'btn-secondary'} btn-enabled tooltip btn-nhi-mark-as-read"> <i class="fas fa-book"></i> <span>${isRead ? 'Mark as unread' : 'Mark as read'}</span> <div class="top">${isRead ? 'Remove from read list' : 'Mark this manga as read'}<i></i></div> </a> `; // Insert the button const buttonElement = document.createElement('div'); buttonElement.innerHTML = buttonHTML; const button = buttonElement.firstElementChild; // Add click event listener using shared method this.addButtonEventHandling(button, galleryId); // Insert button into the .buttons container buttonsContainer.appendChild(button); } /** * Add event handling to mark-as-read button */ addButtonEventHandling(button, galleryId) { button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const originalText = button.querySelector('span').textContent; button.querySelector('span').textContent = 'Processing...'; button.style.opacity = '0.7'; try { await this.toggleReadStatus(galleryId); const newIsRead = this.isRead(galleryId); button.querySelector('span').textContent = newIsRead ? 'Mark as unread' : 'Mark as read'; if (button.querySelector('.top')) { button.querySelector('.top').innerHTML = `${newIsRead ? 'Remove from read list' : 'Mark this manga as read'}<i></i>`; } if (newIsRead) { button.classList.remove('btn-secondary'); button.classList.add('btn-success'); this.showMarkNotification('Gallery marked as read!'); } else { button.classList.remove('btn-success'); button.classList.add('btn-secondary'); this.showMarkNotification('Gallery marked as unread!'); } button.style.transform = 'scale(1.05)'; setTimeout(() => { button.style.transform = 'scale(1)'; }, 200); } catch (error) { console.error('Error toggling read status:', error); button.querySelector('span').textContent = originalText; this.showMarkNotification('Error updating read status', 'error'); } button.style.opacity = '1'; }); } /** * Check if user is on the last page and auto-mark if enabled */ async checkLastPage(galleryId) { // Look for the specific page number button to determine current and total pages const pageNumberButton = document.querySelector('.page-number.btn.btn-unstyled'); if (!pageNumberButton) { console.log('Page number button not found, skipping auto-mark check'); return; } const currentPageSpan = pageNumberButton.querySelector('.current'); const totalPagesSpan = pageNumberButton.querySelector('.num-pages'); if (!currentPageSpan || !totalPagesSpan) { console.log('Current or total page spans not found'); return; } const currentPage = parseInt(currentPageSpan.textContent.trim()); const totalPages = parseInt(totalPagesSpan.textContent.trim()); console.log(`Auto-mark check: Page ${currentPage} of ${totalPages}`); // If we're on the last page, mark as read if (currentPage === totalPages && totalPages > 1) { console.log(`Reached last page (${currentPage}/${totalPages}), marking as read`); // For auto-mark, we need to cache the cover image from the first page await this.cacheGalleryDataFromFirstPage(galleryId); await this.markAsRead(galleryId); this.showAutoMarkNotification(); } } /** * Show notification when gallery is auto-marked as read */ showAutoMarkNotification() { this.showMarkNotification('Gallery automatically marked as read!'); } /** * Show notification for mark as read actions */ showMarkNotification(message, type = 'success') { const notification = document.createElement('div'); const backgroundColor = type === 'error' ? 'rgba(244, 67, 54, 0.9)' : (type === 'info' ? 'rgba(33, 150, 243, 0.9)' : 'rgba(46, 125, 50, 0.9)'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${backgroundColor}; color: white; padding: 12px 20px; border-radius: 8px; z-index: 10000; font-size: 14px; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,0.3); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.2); animation: slideInRight 0.4s ease; max-width: 300px; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(100px)'; notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; setTimeout(() => notification.remove(), 300); }, type === 'error' ? 4000 : (type === 'info' ? 3000 : 2000)); } /** * Cache gallery data for Read Manga page */ async cacheGalleryData(galleryId, title = null, coverImageUrl = null) { try { const cachedData = await GM.getValue('readGalleriesCache', {}); // Skip if already cached if (cachedData[galleryId]) return; const galleryInfo = { id: galleryId, title: title || 'Gallery ' + galleryId, thumbnail: coverImageUrl || null, pages: 'Unknown', tags: [], url: `/g/${galleryId}/`, cached: true, cachedAt: new Date().toISOString() }; // Try to get data from current page // Method 1: Check if we're on the gallery page const galleryMatch = window.location.pathname.match(/\/g\/(\d+)\//); if (galleryMatch && galleryMatch[1] === galleryId) { // Get title from h1 or h2 const titleElement = document.querySelector('h1, h2'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } // Get cover image from #cover const coverElement = document.querySelector('#cover img'); if (coverElement && coverElement.src) { galleryInfo.thumbnail = coverElement.src; } // Get page count from page number button const pageNumberButton = document.querySelector('.page-number.btn.btn-unstyled .num-pages'); if (pageNumberButton) { galleryInfo.pages = pageNumberButton.textContent.trim(); } // Get tags from tag containers const tagElements = document.querySelectorAll('.tag-container .tag .name'); galleryInfo.tags = Array.from(tagElements).map(tag => tag.textContent.trim()); } // Method 2: Check if we're on a listing page and can find the gallery const galleryElement = document.querySelector(`[data-gallery-id="${galleryId}"], .gallery a[href*="/g/${galleryId}/"]`); if (galleryElement) { const gallery = galleryElement.closest('.gallery') || galleryElement; const titleElement = gallery.querySelector('.caption'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } const imgElement = gallery.querySelector('img'); if (imgElement && imgElement.src) { galleryInfo.thumbnail = imgElement.src; } } // Save to cache cachedData[galleryId] = galleryInfo; await GM.setValue('readGalleriesCache', cachedData); console.log(`Cached gallery data for ${galleryId}:`, galleryInfo); } catch (error) { console.error('Error caching gallery data:', error); } } /** * Cache gallery data from first page (for auto-mark scenarios) */ async cacheGalleryDataFromFirstPage(galleryId) { try { const cachedData = await GM.getValue('readGalleriesCache', {}); // Skip if already cached if (cachedData[galleryId]) return; const galleryInfo = { id: galleryId, title: 'Gallery ' + galleryId, thumbnail: null, pages: 'Unknown', tags: [], url: `/g/${galleryId}/`, cached: true, cachedAt: new Date().toISOString() }; // Try to get data from current page first (we might be on a gallery page) const galleryMatch = window.location.pathname.match(/\/g\/(\d+)\//); if (galleryMatch && galleryMatch[1] === galleryId) { // Get title from h1 or h2 const titleElement = document.querySelector('h1, h2'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } // Get page count from page number button const pageNumberButton = document.querySelector('.page-number.btn.btn-unstyled .num-pages'); if (pageNumberButton) { galleryInfo.pages = pageNumberButton.textContent.trim(); } // Get tags from tag containers const tagElements = document.querySelectorAll('.tag-container .tag .name'); galleryInfo.tags = Array.from(tagElements).map(tag => tag.textContent.trim()); } // For auto-mark scenarios, try to fetch the cover from the first page try { const firstPageUrl = `/g/${galleryId}/1/`; console.log(`Fetching cover image from first page: ${firstPageUrl}`); const response = await fetch(firstPageUrl); if (response.ok) { const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Get the cover image from #image-container img const coverImg = doc.querySelector('#image-container img'); if (coverImg && coverImg.src) { galleryInfo.thumbnail = coverImg.src; console.log(`Found cover image: ${coverImg.src}`); } // Also try to get title if we don't have it if (galleryInfo.title === `Gallery ${galleryId}`) { const titleElement = doc.querySelector('h1, h2'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } } } } catch (fetchError) { console.log(`Could not fetch first page for gallery ${galleryId}:`, fetchError); // Fallback: try to construct thumbnail URL const thumbnailUrl = `https://t.nhentai.net/galleries/${galleryId}/thumb.jpg`; try { const thumbResponse = await fetch(thumbnailUrl, { method: 'HEAD' }); if (thumbResponse.ok) { galleryInfo.thumbnail = thumbnailUrl; console.log(`Using fallback thumbnail: ${thumbnailUrl}`); } } catch (thumbError) { console.log(`Fallback thumbnail also failed for gallery ${galleryId}`); } } // Save to cache cachedData[galleryId] = galleryInfo; await GM.setValue('readGalleriesCache', cachedData); console.log(`Cached gallery data from first page for ${galleryId}:`, galleryInfo); } catch (error) { console.error('Error caching gallery data from first page:', error); } } } //------------------------ **Hide/Blacklist System** ------------------ class HideBlacklistSystem { constructor() { this.hiddenGalleries = new Set(); this.hiddenTitles = new Map(); this.settings = { enabled: true }; this.init(); } async init() { await this.loadSettings(); await this.loadHiddenGalleries(); await this.loadHiddenGalleryTitles(); if (this.settings.enabled) { this.addCSS(); this.applyZipOffsetFlag(); this.applyHiddenFilter(); this.addHideButtons(); this.addGalleryPageHideButton(); this.setupDynamicObserver(); } } async loadSettings() { this.settings.enabled = await GM.getValue('hideBlacklistEnabled', true); this.settings.autoHideByTags = await GM.getValue('autoHideBlacklistedTagsEnabled', false); // Load blacklist tags to support auto-hide by tags const bl = await GM.getValue('blacklistTagsList', ['scat', 'guro', 'vore', 'ryona', 'snuff']); this.settings.blacklistTags = Array.isArray(bl) ? bl.map(t => String(t).toLowerCase().trim()) : []; } async loadHiddenGalleries() { try { const list = await GM.getValue('hiddenGalleries', []); this.hiddenGalleries = new Set(Array.isArray(list) ? list : []); } catch (e) { console.error('Error loading hidden galleries:', e); this.hiddenGalleries = new Set(); } } async saveHiddenGalleries() { try { await GM.setValue('hiddenGalleries', Array.from(this.hiddenGalleries)); } catch (e) { console.error('Error saving hidden galleries:', e); } } async loadHiddenGalleryTitles() { try { const obj = await GM.getValue('hiddenGalleryTitles', {}); const entries = Object.entries(obj || {}); this.hiddenTitles = new Map(entries); } catch (e) { console.error('Error loading hidden gallery titles:', e); this.hiddenTitles = new Map(); } } async saveHiddenGalleryTitles() { try { const obj = {}; this.hiddenTitles.forEach((title, id) => { obj[id] = title; }); await GM.setValue('hiddenGalleryTitles', obj); } catch (e) { console.error('Error saving hidden gallery titles:', e); } } extractGalleryId(url) { if (!url) return null; const match = url.match(/\/g\/(\d+)\//); return match ? match[1] : null; } isHidden(id) { return this.hiddenGalleries.has(id); } /** * Extract tags for a gallery using available DOM hints. * If TagWarningSystem is available, reuse its extractor. */ extractGalleryTags(gallery) { try { if (typeof tagWarningSystem !== 'undefined' && tagWarningSystem && typeof tagWarningSystem.extractGalleryTags === 'function') { const tags = tagWarningSystem.extractGalleryTags(gallery) || []; return Array.isArray(tags) ? tags.map(t => String(t).toLowerCase().trim()) : []; } } catch (_) { /* ignore */ } const tags = []; // Method 2: From tag elements (if available) const tagElements = gallery.querySelectorAll('.tag .name'); tagElements.forEach(tagElement => { const tagName = (tagElement.textContent || '').trim().toLowerCase(); if (tagName) tags.push(tagName); }); // Method 3: From title/caption text if (tags.length === 0) { const caption = gallery.querySelector('.caption'); if (caption) { const title = (caption.textContent || '').toLowerCase(); (this.settings.blacklistTags || []).forEach(tag => { if (tag && title.includes(tag)) tags.push(tag); }); } } return tags; } galleryHasBlacklistedTags(gallery) { const tags = this.extractGalleryTags(gallery); if (!tags || !tags.length) return false; const set = new Set((this.settings.blacklistTags || []).map(t => String(t).toLowerCase().trim())); return tags.some(t => set.has(String(t).toLowerCase().trim())); } async hideGallery(id, title) { if (!id) return; this.hiddenGalleries.add(id); if (title && String(title).trim()) { try { this.hiddenTitles.set(id, String(title).trim()); await this.saveHiddenGalleryTitles(); } catch (_) { /* ignore */ } } await this.saveHiddenGalleries(); this.updateGalleryVisibility(id); } async unhideGallery(id) { if (!id) return; this.hiddenGalleries.delete(id); try { if (this.hiddenTitles.has(id)) { this.hiddenTitles.delete(id); await this.saveHiddenGalleryTitles(); } } catch (_) { /* ignore */ } await this.saveHiddenGalleries(); this.updateGalleryVisibility(id); } async toggleHidden(id, title) { if (this.isHidden(id)) { await this.unhideGallery(id); } else { await this.hideGallery(id, title); } } addCSS() { const css = ` .hide-manga-btn { position: absolute; top: 5px; left: 5px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.7); border: none; border-radius: 50%; color: #fff; cursor: pointer; font-size: 12px; z-index: 10; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; } .hide-manga-btn:hover { background: rgba(0, 0, 0, 0.9); transform: scale(1.1); } .hide-manga-btn.hidden { background: rgba(183, 28, 28, 0.85); color: #fff; } .gallery.hidden-by-blacklist { display: none !important; } /* Offset when a top download-zip bar is present */ body.nhp-zip-offset .hide-manga-btn { top: 40px; } .btn-nhi-hide { display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 6px; text-decoration: none; font-weight: 500; transition: all 0.3s ease; border: 1px solid transparent; cursor: pointer; user-select: none; } .btn-nhi-hide:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); } .btn-nhi-hide.btn-danger { background: linear-gradient(135deg, #b71c1c 0%, #8e0000 100%); color: white; } `; GM.addStyle(css); } addHideButtons() { if (window.location.pathname === '/read-manga/' || window.location.hash === '#read-manga' || document.body.classList.contains('read-manga-active') || document.querySelector('.read-manga-page')) { return; } const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { if (gallery.classList.contains('read-manga-gallery')) return; if (gallery.querySelector('.hide-manga-btn')) return; const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const id = this.extractGalleryId(coverLink.getAttribute('href')); if (!id) return; // Capture title for storage when hiding from listings const captionEl = gallery.querySelector('.caption'); const titleText = captionEl ? (captionEl.textContent || '').trim() : ''; const btn = document.createElement('button'); btn.className = 'hide-manga-btn'; const hidden = this.isHidden(id); btn.title = hidden ? 'Unhide' : 'Hide'; btn.textContent = hidden ? '✕' : '–'; if (hidden) btn.classList.add('hidden'); btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); await this.toggleHidden(id, titleText); }); gallery.appendChild(btn); }); this.applyHiddenFilter(); } applyHiddenFilter() { if (document.querySelector('.read-manga-page')) return; const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const id = this.extractGalleryId(coverLink.getAttribute('href')); if (!id) return; const manualHidden = this.isHidden(id); const autoHidden = this.settings.autoHideByTags && this.galleryHasBlacklistedTags(gallery); const hidden = (manualHidden || autoHidden) && this.settings.enabled; gallery.classList.toggle('hidden-by-blacklist', hidden); const btn = gallery.querySelector('.hide-manga-btn'); if (btn) { btn.classList.toggle('hidden', hidden); btn.title = hidden ? 'Unhide' : 'Hide'; btn.textContent = hidden ? '✕' : '–'; } }); } updateGalleryVisibility(galleryId) { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const coverLink = gallery.querySelector('.cover'); if (!coverLink) return; const id = this.extractGalleryId(coverLink.getAttribute('href')); if (id !== galleryId) return; const hidden = this.isHidden(id) && this.settings.enabled; gallery.classList.toggle('hidden-by-blacklist', hidden); const btn = gallery.querySelector('.hide-manga-btn'); if (btn) { btn.classList.toggle('hidden', hidden); btn.title = hidden ? 'Unhide' : 'Hide'; btn.textContent = hidden ? '✕' : '–'; } }); } addGalleryPageHideButton() { const match = window.location.pathname.match(/\/g\/(\d+)\//); if (!match) return; const id = match[1]; const container = document.querySelector('.buttons'); if (!container) return; if (document.getElementById('nhi-hide-button')) return; const hidden = this.isHidden(id); const html = ` <a href="#" id="nhi-hide-button" class="btn ${hidden ? 'btn-danger' : 'btn-secondary'} btn-enabled tooltip btn-nhi-hide"> <i class="fas fa-ban"></i> <span>${hidden ? 'Unhide' : 'Hide/Blacklist'}</span> <div class="top">${hidden ? 'Show this manga again' : 'Hide this manga across listings'}<i></i></div> </a> `; const wrap = document.createElement('div'); wrap.innerHTML = html; const btn = wrap.firstElementChild; btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const span = btn.querySelector('span'); const original = span ? span.textContent : ''; if (span) span.textContent = 'Processing...'; btn.style.opacity = '0.7'; try { const titleText = this.getCurrentGalleryTitle() || ''; await this.toggleHidden(id, titleText); const newHidden = this.isHidden(id); if (span) span.textContent = newHidden ? 'Unhide' : 'Hide/Blacklist'; if (btn.querySelector('.top')) { btn.querySelector('.top').innerHTML = `${newHidden ? 'Show this manga again' : 'Hide this manga across listings'}<i></i>`; } btn.classList.toggle('btn-danger', newHidden); btn.classList.toggle('btn-secondary', !newHidden); } catch (_) { if (span) span.textContent = original; } finally { btn.style.opacity = '1'; } }); container.appendChild(btn); this.updateGalleryVisibility(id); } getCurrentGalleryTitle() { try { // nhentai gallery page often has #info h1 or .title const h1 = document.querySelector('#info h1'); if (h1 && h1.textContent) return h1.textContent.trim(); const titleEl = document.querySelector('.title') || document.querySelector('#info .title'); if (titleEl && titleEl.textContent) return titleEl.textContent.trim(); if (document.title) return document.title.replace(/\s*-\s*nhentai.*/i, '').trim(); } catch (_) { /* ignore */ } return ''; } setupDynamicObserver() { const observer = new MutationObserver((mutations) => { let hasNewGalleries = false; for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.classList && node.classList.contains('gallery')) { hasNewGalleries = true; } else if (node.querySelectorAll) { const galleries = node.querySelectorAll('.gallery'); if (galleries && galleries.length) { hasNewGalleries = true; } } }); } if (hasNewGalleries) { this.addHideButtons(); } this.applyZipOffsetFlag(); }); observer.observe(document.body, { childList: true, subtree: true }); } applyZipOffsetFlag() { try { const zipBtn = document.querySelector('#content > div.container.index-container.index-popular > div:nth-child(2) > button.btn.btn-secondary.nhentai-helper-btn.download-zip-btn'); if (zipBtn) { document.body.classList.add('nhp-zip-offset'); } else { document.body.classList.remove('nhp-zip-offset'); } } catch (e) { // ignore } } } // Initialize Mark as Read System let markAsReadSystem; async function initMarkAsReadSystem() { const enabled = await GM.getValue('markAsReadEnabled', true); if (enabled) { markAsReadSystem = new MarkAsReadSystem(); } } // Initialize on page load and when navigating if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initMarkAsReadSystem); } else { initMarkAsReadSystem(); } // Initialize Hide/Blacklist System let hideBlacklistSystem; async function initHideBlacklistSystem() { const enabled = await GM.getValue('hideBlacklistEnabled', true); if (enabled) { hideBlacklistSystem = new HideBlacklistSystem(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initHideBlacklistSystem); } else { initHideBlacklistSystem(); } //------------------------ **Enhanced Opacity/Fade System** ------------------ /** * Enhanced Opacity/Fade Configuration System * Provides configurable opacity settings for non-English and read galleries */ class OpacityFadeSystem { constructor() { this.settings = { nonEnglishOpacity: 0.2, readGalleriesOpacity: 0.6, showNonEnglish: 'show' // 'show', 'hide', 'fade' }; this.init(); } /** * Initialize the opacity/fade system */ async init() { await this.loadSettings(); this.addCSS(); this.applyOpacitySettings(); this.setupRealTimePreview(); } /** * Load settings from GM storage */ async loadSettings() { this.settings.nonEnglishOpacity = await GM.getValue('nonEnglishOpacity', 0.2); this.settings.readGalleriesOpacity = await GM.getValue('readGalleriesOpacity', 0.6); this.settings.showNonEnglish = await GM.getValue('showNonEnglish', 'show'); } /** * Add CSS for opacity/fade effects */ addCSS() { const css = ` /* Enhanced Opacity System */ :root { --non-english-opacity: ${this.settings.nonEnglishOpacity}; --read-galleries-opacity: ${this.settings.readGalleriesOpacity}; } /* Non-English gallery fading */ .gallery:not([data-tags~='12227']) .cover img, .gallery:not([data-tags~='12227']) .cover .caption { opacity: var(--non-english-opacity); transition: opacity 0.3s ease; } .gallery:not([data-tags~='12227']):hover .cover img, .gallery:not([data-tags~='12227']):hover .cover .caption { opacity: 1; } /* Read galleries fading (handled by MarkAsReadSystem) */ .gallery.read-gallery .cover img, .gallery.read-gallery .cover .caption { opacity: var(--read-galleries-opacity) !important; transition: opacity 0.3s ease; } .gallery.read-gallery:hover .cover img, .gallery.read-gallery:hover .cover .caption { opacity: 1 !important; } /* Hide non-English galleries when showNonEnglish is 'hide' */ body[data-show-non-english="hide"] .gallery:not([data-tags~='12227']) { display: none !important; } /* Fade non-English galleries when showNonEnglish is 'fade' */ body[data-show-non-english="fade"] .gallery:not([data-tags~='12227']) .cover img, body[data-show-non-english="fade"] .gallery:not([data-tags~='12227']) .cover .caption { opacity: var(--non-english-opacity); } /* Show all galleries when showNonEnglish is 'show' */ body[data-show-non-english="show"] .gallery:not([data-tags~='12227']) .cover img, body[data-show-non-english="show"] .gallery:not([data-tags~='12227']) .cover .caption { opacity: 1; } /* Real-time preview styles */ .opacity-preview { border: 2px solid #e63946 !important; box-shadow: 0 0 10px rgba(230, 57, 70, 0.5) !important; } `; GM.addStyle(css); } /** * Apply opacity settings to the page */ applyOpacitySettings() { // Update CSS custom properties document.documentElement.style.setProperty('--non-english-opacity', this.settings.nonEnglishOpacity); document.documentElement.style.setProperty('--read-galleries-opacity', this.settings.readGalleriesOpacity); // Set body attribute for non-English display mode document.body.setAttribute('data-show-non-english', this.settings.showNonEnglish); } /** * Setup real-time preview for opacity sliders */ setupRealTimePreview() { // Non-English opacity slider const nonEnglishSlider = document.getElementById('nonEnglishOpacity'); if (nonEnglishSlider) { nonEnglishSlider.addEventListener('input', (e) => { const value = parseFloat(e.target.value); this.previewNonEnglishOpacity(value); }); nonEnglishSlider.addEventListener('change', async (e) => { const value = parseFloat(e.target.value); this.settings.nonEnglishOpacity = value; await GM.setValue('nonEnglishOpacity', value); this.applyOpacitySettings(); this.clearPreview(); }); } // Read galleries opacity slider const readSlider = document.getElementById('readGalleriesOpacity'); if (readSlider) { readSlider.addEventListener('input', (e) => { const value = parseFloat(e.target.value); this.previewReadGalleriesOpacity(value); }); readSlider.addEventListener('change', async (e) => { const value = parseFloat(e.target.value); this.settings.readGalleriesOpacity = value; await GM.setValue('readGalleriesOpacity', value); this.applyOpacitySettings(); this.clearPreview(); }); } // Show non-English select const showNonEnglishSelect = document.getElementById('showNonEnglishSelect'); if (showNonEnglishSelect) { showNonEnglishSelect.addEventListener('change', async (e) => { this.settings.showNonEnglish = e.target.value; await GM.setValue('showNonEnglish', e.target.value); this.applyOpacitySettings(); }); } } /** * Preview non-English opacity changes */ previewNonEnglishOpacity(opacity) { document.documentElement.style.setProperty('--non-english-opacity', opacity); // Add preview styling to non-English galleries const nonEnglishGalleries = document.querySelectorAll('.gallery:not([data-tags~="12227"])'); nonEnglishGalleries.forEach(gallery => { gallery.classList.add('opacity-preview'); }); } /** * Preview read galleries opacity changes */ previewReadGalleriesOpacity(opacity) { document.documentElement.style.setProperty('--read-galleries-opacity', opacity); // Add preview styling to read galleries const readGalleries = document.querySelectorAll('.gallery.read-gallery'); readGalleries.forEach(gallery => { gallery.classList.add('opacity-preview'); }); } /** * Clear preview styling */ clearPreview() { const previewElements = document.querySelectorAll('.opacity-preview'); previewElements.forEach(element => { element.classList.remove('opacity-preview'); }); } /** * Reset to default values */ async resetToDefaults() { this.settings.nonEnglishOpacity = 0.2; this.settings.readGalleriesOpacity = 0.6; await GM.setValue('nonEnglishOpacity', 0.2); await GM.setValue('readGalleriesOpacity', 0.6); // Update UI const nonEnglishSlider = document.getElementById('nonEnglishOpacity'); const readSlider = document.getElementById('readGalleriesOpacity'); const nonEnglishValue = document.getElementById('nonEnglishOpacityValue'); const readValue = document.getElementById('readGalleriesOpacityValue'); if (nonEnglishSlider) { nonEnglishSlider.value = 0.2; if (nonEnglishValue) nonEnglishValue.textContent = '0.2'; } if (readSlider) { readSlider.value = 0.6; if (readValue) readValue.textContent = '0.6'; } this.applyOpacitySettings(); } } // Initialize Enhanced Opacity/Fade System let opacityFadeSystem; async function initOpacityFadeSystem() { opacityFadeSystem = new OpacityFadeSystem(); } // Initialize on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initOpacityFadeSystem); } else { initOpacityFadeSystem(); } //------------------------ **Enhanced Opacity/Fade System** ------------------ //------------------------ **Improved Language Detection System** ------------------ /** * Improved Language Detection System * Uses DOM parsing of existing tag elements with fallback to title-based heuristics */ class LanguageDetectionSystem { constructor() { this.languageMap = { '12227': 'english', '6346': 'japanese', '29963': 'chinese', '19440': 'korean', '16934': 'spanish', '1': 'french', '5973': 'german', '21613': 'italian', '11261': 'portuguese', '18334': 'russian', '22830': 'thai', '28288': 'vietnamese' }; this.titleLanguagePatterns = { english: /^[a-zA-Z0-9\s\[\]\(\)\-_!@#$%^&*+={}|\\:";'<>?,./~`]+$/, japanese: /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/, chinese: /[\u4E00-\u9FFF]/, korean: /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/ }; this.init(); } /** * Initialize the language detection system */ init() { this.detectAndMarkLanguages(); this.setupLanguageObserver(); } /** * Detect and mark languages for all galleries on the page */ detectAndMarkLanguages() { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const language = this.detectGalleryLanguage(gallery); if (language) { gallery.setAttribute('data-detected-language', language); this.addLanguageFlag(gallery, language); } }); } /** * Detect the language of a specific gallery */ detectGalleryLanguage(gallery) { // Method 1: Parse existing data-tags attribute const dataTags = gallery.getAttribute('data-tags'); if (dataTags) { const language = this.detectLanguageFromTags(dataTags); if (language) return language; } // Method 2: Parse tag elements in gallery listings const tagElements = gallery.querySelectorAll('.tag'); if (tagElements.length > 0) { const language = this.detectLanguageFromTagElements(tagElements); if (language) return language; } // Method 3: Fallback to title-based heuristic const titleElement = gallery.querySelector('.caption'); if (titleElement) { const language = this.detectLanguageFromTitle(titleElement.textContent); if (language) return language; } // Default to unknown if no language detected return 'unknown'; } /** * Detect language from data-tags attribute */ detectLanguageFromTags(dataTags) { const tags = dataTags.split(' '); for (const [tagId, language] of Object.entries(this.languageMap)) { if (tags.includes(tagId)) { return language; } } return null; } /** * Detect language from tag elements */ detectLanguageFromTagElements(tagElements) { for (const tagElement of tagElements) { const tagText = tagElement.textContent.toLowerCase().trim(); // Check for language: prefix if (tagText.startsWith('language:')) { const language = tagText.replace('language:', '').trim(); if (Object.values(this.languageMap).includes(language)) { return language; } } // Check for direct language matches if (Object.values(this.languageMap).includes(tagText)) { return tagText; } } return null; } /** * Detect language from title using heuristics */ detectLanguageFromTitle(title) { if (!title) return null; // Check for Japanese characters if (this.titleLanguagePatterns.japanese.test(title)) { return 'japanese'; } // Check for Chinese characters if (this.titleLanguagePatterns.chinese.test(title)) { return 'chinese'; } // Check for Korean characters if (this.titleLanguagePatterns.korean.test(title)) { return 'korean'; } // Check if it's primarily English (ASCII characters) if (this.titleLanguagePatterns.english.test(title)) { return 'english'; } return null; } /** * Add language flag to gallery */ addLanguageFlag(gallery, language) { const caption = gallery.querySelector('.caption'); if (!caption) return; // Remove all existing flag elements (both old and new systems) const existingFlags = gallery.querySelectorAll('.language-flag, .overlayFlag'); existingFlags.forEach(flag => flag.remove()); // Remove inline flag images from caption text content const captionTextNodes = Array.from(caption.childNodes).filter(node => node.nodeType === Node.TEXT_NODE); captionTextNodes.forEach(textNode => { // Check if this text node contains inline flag HTML if (textNode.textContent && textNode.textContent.includes('<img')) { // This shouldn't happen with text nodes, but let's be safe textNode.remove(); } }); // Remove inline flag images that might be direct children const inlineFlags = caption.querySelectorAll('img[style*="margin-right: 5px"][style*="vertical-align: middle"]'); inlineFlags.forEach(flag => flag.remove()); // Clean up caption innerHTML to remove any remaining inline flag HTML if (caption.innerHTML.includes('style="margin-right: 5px; vertical-align: middle; height: 12px"')) { // Extract just the text content, removing any inline flag HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = caption.innerHTML; // Remove all img elements with the inline flag styling const inlineImgs = tempDiv.querySelectorAll('img[style*="margin-right: 5px"]'); inlineImgs.forEach(img => img.remove()); // Get the cleaned text content const cleanText = tempDiv.textContent || tempDiv.innerText || ''; // Preserve any matching-tags divs const matchingTagsDiv = caption.querySelector('.matching-tags'); // Clear caption and add back clean text caption.innerHTML = cleanText; // Re-add matching tags if they existed if (matchingTagsDiv) { caption.appendChild(matchingTagsDiv); } } const flagUrls = { english: "https://i.imgur.com/vSnHmmi.gif", japanese: "https://i.imgur.com/GlArpuS.gif", chinese: "https://i.imgur.com/7B55DYm.gif", korean: "https://i.imgur.com/placeholder-kr.gif", // Add actual Korean flag URL spanish: "https://i.imgur.com/placeholder-es.gif", // Add actual Spanish flag URL // Add more flag URLs as needed }; if (flagUrls[language]) { gallery.setAttribute('data-language-flagged', '1'); const flag = document.createElement('img'); flag.className = 'language-flag overlayFlag'; flag.src = flagUrls[language]; flag.alt = language; flag.title = language.charAt(0).toUpperCase() + language.slice(1); // Add the new flag to caption area caption.appendChild(flag); } } /** * Setup observer for dynamically loaded content */ setupLanguageObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the added node is a gallery if (node.classList && node.classList.contains('gallery')) { const language = this.detectGalleryLanguage(node); if (language) { node.setAttribute('data-detected-language', language); this.addLanguageFlag(node, language); } } // Check for galleries within the added node const galleries = node.querySelectorAll && node.querySelectorAll('.gallery'); if (galleries) { galleries.forEach(gallery => { const language = this.detectGalleryLanguage(gallery); if (language) { gallery.setAttribute('data-detected-language', language); this.addLanguageFlag(gallery, language); } }); } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } /** * Get language statistics for the current page */ getLanguageStats() { const galleries = document.querySelectorAll('.gallery'); const stats = {}; galleries.forEach(gallery => { const language = gallery.getAttribute('data-detected-language') || 'unknown'; stats[language] = (stats[language] || 0) + 1; }); return stats; } /** * Filter galleries by language */ filterByLanguage(language) { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { const galleryLanguage = gallery.getAttribute('data-detected-language'); if (language === 'all') { gallery.style.display = ''; } else if (galleryLanguage === language) { gallery.style.display = ''; } else { gallery.style.display = 'none'; } }); } } // Initialize Language Detection System let languageDetectionSystem; async function initLanguageDetectionSystem() { languageDetectionSystem = new LanguageDetectionSystem(); } // Initialize on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initLanguageDetectionSystem); } else { initLanguageDetectionSystem(); } //------------------------ **Improved Language Detection System** ------------------ //------------------------ **Tag Warning & Blacklist System** ------------------ /** * Tag Warning & Blacklist System * Two-tier system with red blacklist badges and orange warning badges */ class TagWarningSystem { constructor() { this.settings = { enabled: true, blacklistTags: ['scat', 'guro', 'vore', 'ryona', 'snuff'], warningTags: ['ntr', 'netorare', 'cheating', 'ugly bastard', 'mind break'], favoriteTags: [] }; this.init(); } /** * Initialize the tag warning system */ async init() { await this.loadSettings(); if (this.settings.enabled) { this.addCSS(); this.processGalleries(); this.processGalleryPage(); this.setupObserver(); } } /** * Load settings from GM storage */ async loadSettings() { this.settings.enabled = await GM.getValue('tagWarningEnabled', true); this.settings.blacklistTags = await GM.getValue('blacklistTagsList', ['scat', 'guro', 'vore', 'ryona', 'snuff']); this.settings.warningTags = await GM.getValue('warningTagsList', ['ntr', 'netorare', 'cheating', 'ugly bastard', 'mind break']); this.settings.favoriteTags = await GM.getValue('favoriteTagsList', []); } /** * Add CSS for tag warning badges */ addCSS() { const css = ` /* Tag Warning Badges */ .tag-warning-badge { position: absolute; bottom: 5px; left: 5px; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; color: white; z-index: 10; pointer-events: none; text-transform: uppercase; box-shadow: 0 1px 3px rgba(0,0,0,0.3); } .tag-warning-badge.blacklist { background: rgba(244, 67, 54, 0.9); } .tag-warning-badge.warning { background: rgba(255, 152, 0, 0.9); } .tag-warning-badge.favorite { background: rgba(33, 150, 243, 0.9); } /* Multiple badges stacking */ .tag-warning-badge:nth-child(2) { bottom: 25px; } .tag-warning-badge:nth-child(3) { bottom: 45px; } /* Gallery page tag highlighting */ .tag-container .tag.blacklist-tag { background-color: rgba(244, 67, 54, 0.2) !important; border: 1px solid #f44336 !important; color: #f44336 !important; } .tag-container .tag.warning-tag { background-color: rgba(255, 152, 0, 0.2) !important; border: 1px solid #ff9800 !important; color: #ff9800 !important; } .tag-container .tag.favorite-tag { background-color: rgba(33, 150, 243, 0.2) !important; border: 1px solid #2196f3 !important; color: #2196f3 !important; } /* Star button for favoriting tags */ .tag-star-btn { margin-left: 5px; cursor: pointer; color: #666; transition: color 0.3s ease; } .tag-star-btn:hover { color: #2196f3; } .tag-star-btn.favorited { color: #2196f3; } /* Hover effects for badges */ .gallery:hover .tag-warning-badge { opacity: 0.8; } `; GM.addStyle(css); } /** * Process all galleries on the page */ processGalleries() { const galleries = document.querySelectorAll('.gallery'); galleries.forEach(gallery => { this.processGallery(gallery); }); } /** * Process a single gallery for tag warnings */ processGallery(gallery) { const tags = this.extractGalleryTags(gallery); if (!tags.length) return; const warnings = this.analyzeTagsForWarnings(tags); this.addWarningBadges(gallery, warnings); } /** * Extract tags from gallery element */ extractGalleryTags(gallery) { const tags = []; // Method 1: From data-tags attribute const dataTags = gallery.getAttribute('data-tags'); if (dataTags) { // This contains tag IDs, we need to map them to tag names // For now, we'll use other methods } // Method 2: From tag elements (if available) const tagElements = gallery.querySelectorAll('.tag .name'); tagElements.forEach(tagElement => { const tagName = tagElement.textContent.trim().toLowerCase(); if (tagName) tags.push(tagName); }); // Method 3: From title analysis (basic) if (tags.length === 0) { const caption = gallery.querySelector('.caption'); if (caption) { const title = caption.textContent.toLowerCase(); // Check for common tag patterns in titles this.settings.blacklistTags.concat(this.settings.warningTags).forEach(tag => { if (title.includes(tag.toLowerCase())) { tags.push(tag); } }); } } return tags; } /** * Analyze tags for warnings and favorites */ analyzeTagsForWarnings(tags) { const warnings = { blacklist: [], warning: [], favorite: [] }; tags.forEach(tag => { const normalizedTag = tag.toLowerCase().trim(); if (this.settings.blacklistTags.includes(normalizedTag)) { warnings.blacklist.push(normalizedTag); } else if (this.settings.warningTags.includes(normalizedTag)) { warnings.warning.push(normalizedTag); } if (this.settings.favoriteTags.includes(normalizedTag)) { warnings.favorite.push(normalizedTag); } }); return warnings; } /** * Add warning badges to gallery */ addWarningBadges(gallery, warnings) { // Remove existing badges const existingBadges = gallery.querySelectorAll('.tag-warning-badge'); existingBadges.forEach(badge => badge.remove()); // Add blacklist badge if (warnings.blacklist.length > 0) { const badge = this.createWarningBadge('blacklist', this.abbreviateTag(warnings.blacklist[0])); gallery.appendChild(badge); } // Add warning badge if (warnings.warning.length > 0) { const badge = this.createWarningBadge('warning', this.abbreviateTag(warnings.warning[0])); gallery.appendChild(badge); } // Add favorite badge if (warnings.favorite.length > 0) { const badge = this.createWarningBadge('favorite', '★'); gallery.appendChild(badge); } } /** * Create a warning badge element */ createWarningBadge(type, text) { const badge = document.createElement('div'); badge.className = `tag-warning-badge ${type}`; badge.textContent = text; badge.title = `${type.charAt(0).toUpperCase() + type.slice(1)} tag detected`; return badge; } /** * Abbreviate tag names for badges */ abbreviateTag(tag) { const abbreviations = { 'scat': 'SCAT', 'guro': 'GURO', 'vore': 'VORE', 'ryona': 'RYONA', 'snuff': 'SNUFF', 'ntr': 'NTR', 'netorare': 'NTR', 'cheating': 'CHEAT', 'ugly bastard': 'UB', 'mind break': 'MB' }; return abbreviations[tag.toLowerCase()] || tag.substring(0, 4).toUpperCase(); } /** * Process individual gallery page for tag highlighting */ processGalleryPage() { // Check if we're on a gallery page if (!window.location.pathname.match(/\/g\/\d+\//)) return; const tagContainers = document.querySelectorAll('.tag-container'); tagContainers.forEach(container => { const tags = container.querySelectorAll('.tag'); tags.forEach(tagElement => { const tagName = tagElement.querySelector('.name'); if (!tagName) return; const tag = tagName.textContent.trim().toLowerCase(); if (this.settings.blacklistTags.includes(tag)) { tagElement.classList.add('blacklist-tag'); } else if (this.settings.warningTags.includes(tag)) { tagElement.classList.add('warning-tag'); } else if (this.settings.favoriteTags.includes(tag)) { tagElement.classList.add('favorite-tag'); } // Add star button for favoriting this.addStarButton(tagElement, tag); }); }); } /** * Add star button for favoriting tags */ addStarButton(tagElement, tag) { // Skip if star button already exists if (tagElement.querySelector('.tag-star-btn')) return; const starBtn = document.createElement('span'); starBtn.className = 'tag-star-btn'; starBtn.innerHTML = this.settings.favoriteTags.includes(tag) ? '★' : '☆'; starBtn.title = 'Add to favorites'; if (this.settings.favoriteTags.includes(tag)) { starBtn.classList.add('favorited'); } starBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); await this.toggleFavoriteTag(tag); // Update star appearance if (this.settings.favoriteTags.includes(tag)) { starBtn.innerHTML = '★'; starBtn.classList.add('favorited'); tagElement.classList.add('favorite-tag'); } else { starBtn.innerHTML = '☆'; starBtn.classList.remove('favorited'); tagElement.classList.remove('favorite-tag'); } // Update settings form if open this.updateFavoriteTagsDisplay(); }); tagElement.appendChild(starBtn); } /** * Toggle favorite status of a tag */ async toggleFavoriteTag(tag) { const normalizedTag = tag.toLowerCase().trim(); const index = this.settings.favoriteTags.indexOf(normalizedTag); if (index > -1) { this.settings.favoriteTags.splice(index, 1); } else { this.settings.favoriteTags.push(normalizedTag); } await GM.setValue('favoriteTagsList', this.settings.favoriteTags); } /** * Update favorite tags display in settings */ updateFavoriteTagsDisplay() { const favoriteTagsTextarea = document.getElementById('favoriteTags'); if (favoriteTagsTextarea) { favoriteTagsTextarea.value = this.settings.favoriteTags.join(', '); } } /** * Setup observer for dynamically loaded content */ setupObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the added node is a gallery if (node.classList && node.classList.contains('gallery')) { this.processGallery(node); } // Check for galleries within the added node const galleries = node.querySelectorAll && node.querySelectorAll('.gallery'); if (galleries) { galleries.forEach(gallery => { this.processGallery(gallery); }); } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } } // Initialize Tag Warning System let tagWarningSystem; async function initTagWarningSystem() { const enabled = await GM.getValue('tagWarningEnabled', true); if (enabled) { tagWarningSystem = new TagWarningSystem(); } } // Initialize on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initTagWarningSystem); } else { initTagWarningSystem(); } //------------------------ **Tag Warning & Blacklist System** ------------------ //------------------------ **Advanced Search URL Generation System** ------------------ /** * Advanced Search URL Generation System * Replaces existing English-only buttons with context-aware advanced search URLs */ class AdvancedSearchSystem { constructor() { this.init(); } /** * Initialize the advanced search system */ init() { this.replaceEnglishOnlyButtons(); this.addAdvancedSearchButtons(); this.setupObserver(); } /** * Replace existing English-only buttons with advanced search URLs */ replaceEnglishOnlyButtons() { // Find existing English filter buttons const englishButtons = document.querySelectorAll('a[href*="language%3A%22english%22"], a[href*="language:english"]'); englishButtons.forEach(button => { this.enhanceEnglishButton(button); }); } /** * Enhance an existing English button with advanced search functionality */ enhanceEnglishButton(button) { const currentUrl = button.getAttribute('href'); const enhancedUrl = this.generateAdvancedSearchUrl(currentUrl); if (enhancedUrl !== currentUrl) { button.setAttribute('href', enhancedUrl); button.title = 'Advanced English search with current context'; } } /** * Add advanced search buttons to relevant pages */ addAdvancedSearchButtons() { // Add to tag pages this.addTagPageSearchButtons(); // Add to artist pages this.addArtistPageSearchButtons(); // Add to character pages this.addCharacterPageSearchButtons(); // Add to parody pages this.addParodyPageSearchButtons(); // Add to group pages this.addGroupPageSearchButtons(); } /** * Add advanced search buttons to tag pages */ addTagPageSearchButtons() { if (!window.location.pathname.startsWith('/tag/')) return; const pathParts = window.location.pathname.split('/'); if (pathParts.length < 3) return; const tagName = decodeURIComponent(pathParts[2]); const searchUrl = this.buildTagSearchUrl('tag', tagName); this.addSearchButton('English + This Tag', searchUrl, 'Search for English galleries with this tag'); } /** * Add advanced search buttons to artist pages */ addArtistPageSearchButtons() { if (!window.location.pathname.startsWith('/artist/')) return; const pathParts = window.location.pathname.split('/'); if (pathParts.length < 3) return; const artistName = decodeURIComponent(pathParts[2]); // Learn artist name when visiting artist page try { addKnownArtists([artistName]); } catch (_) {} const searchUrl = this.buildTagSearchUrl('artist', artistName); this.addSearchButton('English + This Artist', searchUrl, 'Search for English galleries by this artist'); } /** * Add advanced search buttons to character pages */ addCharacterPageSearchButtons() { if (!window.location.pathname.startsWith('/character/')) return; const pathParts = window.location.pathname.split('/'); if (pathParts.length < 3) return; const characterName = decodeURIComponent(pathParts[2]); const searchUrl = this.buildTagSearchUrl('character', characterName); this.addSearchButton('English + This Character', searchUrl, 'Search for English galleries with this character'); } /** * Add advanced search buttons to parody pages */ addParodyPageSearchButtons() { if (!window.location.pathname.startsWith('/parody/')) return; const pathParts = window.location.pathname.split('/'); if (pathParts.length < 3) return; const parodyName = decodeURIComponent(pathParts[2]); const searchUrl = this.buildTagSearchUrl('parody', parodyName); this.addSearchButton('English + This Parody', searchUrl, 'Search for English galleries of this parody'); } /** * Add advanced search buttons to group pages */ addGroupPageSearchButtons() { if (!window.location.pathname.startsWith('/group/')) return; const pathParts = window.location.pathname.split('/'); if (pathParts.length < 3) return; const groupName = decodeURIComponent(pathParts[2]); const searchUrl = this.buildTagSearchUrl('group', groupName); this.addSearchButton('English + This Group', searchUrl, 'Search for English galleries by this group'); } /** * Build advanced search URL for tag-based searches */ buildTagSearchUrl(namespace, value) { // Clean the value const cleanValue = value.replace(/['"]/g, '').trim(); // Build the search query using nhentai's advanced search syntax const query = `${namespace}:"${cleanValue}" language:"english"`; // Encode the query const encodedQuery = encodeURIComponent(query); return `https://nhentai.net/search/?q=${encodedQuery}`; } /** * Generate advanced search URL from existing URL */ generateAdvancedSearchUrl(currentUrl) { try { const url = new URL(currentUrl, window.location.origin); const searchParams = new URLSearchParams(url.search); let query = searchParams.get('q') || ''; // If it's already an advanced search, return as is if (query.includes('language:"english"') || query.includes('language%3A%22english%22')) { return currentUrl; } // Add English language filter if not present if (query) { query += ' language:"english"'; } else { query = 'language:"english"'; } searchParams.set('q', query); url.search = searchParams.toString(); return url.toString(); } catch (error) { console.error('Error generating advanced search URL:', error); return currentUrl; } } /** * Add a search button to the page */ addSearchButton(text, url, title) { // Find a suitable container for the button const container = this.findButtonContainer(); if (!container) return; const button = document.createElement('a'); button.className = 'btn btn-primary advanced-search-btn'; button.href = url; button.textContent = text; button.title = title; button.style.marginLeft = '10px'; // Add icon const icon = document.createElement('i'); icon.className = 'fa fa-search'; icon.style.marginRight = '5px'; button.insertBefore(icon, button.firstChild); container.appendChild(button); } /** * Find suitable container for search buttons */ findButtonContainer() { // Try to find existing button containers const containers = [ document.querySelector('.pagination'), document.querySelector('.sort'), document.querySelector('h1'), document.querySelector('.container h2'), document.querySelector('#content') ]; for (const container of containers) { if (container) { return container; } } return null; } /** * Create quick search buttons for favorite tags */ createFavoriteTagSearchButtons() { // Get favorite tags from storage GM.getValue('favoriteTagsList', []).then(favoriteTags => { if (favoriteTags.length === 0) return; const container = this.createFavoriteSearchContainer(); if (!container) return; favoriteTags.forEach(tag => { const searchUrl = this.buildTagSearchUrl('tag', tag); const button = this.createQuickSearchButton(tag, searchUrl); container.appendChild(button); }); }); } /** * Create container for favorite tag search buttons */ createFavoriteSearchContainer() { // Check if container already exists let container = document.getElementById('favorite-tag-searches'); if (container) return container; container = document.createElement('div'); container.id = 'favorite-tag-searches'; container.style.cssText = ` margin: 15px 0; padding: 10px; background: rgba(0,0,0,0.1); border-radius: 5px; `; const title = document.createElement('h4'); title.textContent = 'Quick Search: Favorite Tags'; title.style.marginBottom = '10px'; container.appendChild(title); // Find insertion point const insertionPoint = document.querySelector('.container') || document.body; insertionPoint.insertBefore(container, insertionPoint.firstChild); return container; } /** * Create quick search button for a tag */ createQuickSearchButton(tag, url) { const button = document.createElement('a'); button.className = 'btn btn-secondary'; button.href = url; button.textContent = tag; button.title = `Search English galleries with tag: ${tag}`; button.style.cssText = ` margin: 2px; padding: 5px 10px; font-size: 12px; text-decoration: none; `; return button; } /** * Setup observer for dynamically loaded content */ setupObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check for new English buttons const englishButtons = node.querySelectorAll && node.querySelectorAll('a[href*="language%3A%22english%22"], a[href*="language:english"]'); if (englishButtons) { englishButtons.forEach(button => { this.enhanceEnglishButton(button); }); } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } /** * Generate search URL with multiple parameters */ generateMultiParameterSearch(params) { const queryParts = []; Object.entries(params).forEach(([key, value]) => { if (value) { queryParts.push(`${key}:"${value}"`); } }); const query = queryParts.join(' '); const encodedQuery = encodeURIComponent(query); return `https://nhentai.net/search/?q=${encodedQuery}`; } } // Initialize Advanced Search System let advancedSearchSystem; async function initAdvancedSearchSystem() { advancedSearchSystem = new AdvancedSearchSystem(); } // Initialize on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAdvancedSearchSystem); } else { initAdvancedSearchSystem(); } //------------------------ **Advanced Search URL Generation System** ------------------ //------------------------ **Read Manga Page System** ------------------ /** * Read Manga Page System * Creates a dedicated page to view all read manga */ class ReadMangaPageSystem { constructor() { this.pageUrl = '/read-manga/'; this.currentDisplayLimit = null; this.showMoreStep = 50; this.init(); } /** * Initialize the read manga page system */ async init() { this.handlePageRouting(); } /** * Add navigation link to the main menu using the existing pattern */ async addNavigationLink() { // Check if read manga page is enabled const enabled = await this.checkIfEnabled(); if (!enabled) return; // Check if link already exists if (document.querySelector('a[href="/read-manga/"]')) return; // Create the read manga button following the same pattern as other nav items const readMangaButtonHtml = ` <li> <a href="/read-manga/"> <i class="fas fa-book-open"></i> Read Manga </a> </li> `; const readMangaButton = $(readMangaButtonHtml); // Append to dropdown menu const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(readMangaButton); // Append to main menu const menu = $('ul.menu.left'); menu.append(readMangaButton); // Add click handler for navigation readMangaButton.find('a').on('click', (e) => { e.preventDefault(); this.navigateToReadMangaPage(); }); // Call updateMenuOrder to ensure proper tab order setTimeout(() => { if (typeof updateMenuOrder === 'function') { updateMenuOrder(); } }, 100); } /** * Check if read manga page should be enabled */ async checkIfEnabled() { // Check if mark as read system is enabled const markAsReadEnabled = await GM.getValue('markAsReadEnabled', true); const readMangaPageEnabled = await GM.getValue('readMangaPageEnabled', true); return markAsReadEnabled && readMangaPageEnabled; } /** * Handle page routing for the read manga page */ handlePageRouting() { // Check if we're on the read manga page if (window.location.pathname === this.pageUrl || window.location.hash === '#read-manga') { this.renderReadMangaPage(); } } /** * Navigate to the read manga page */ navigateToReadMangaPage() { // Update URL without page reload history.pushState({}, 'Read Manga - nhentai', this.pageUrl); this.renderReadMangaPage(); } /** * Render the read manga page */ async renderReadMangaPage() { // Get read galleries from storage const readGalleries = await GM.getValue('readGalleries', []); if (readGalleries.length === 0) { this.renderEmptyState(); return; } // Determine current display limit (supports Show More increment) const baseLimit = this.currentDisplayLimit ?? await GM.getValue('maxReadMangaDisplay', 100); // Fetch gallery data for read galleries const galleryData = await this.fetchGalleryData(readGalleries, baseLimit); // Apply sorting based on current selection const sortedData = await this.sortGalleryData(galleryData); await this.renderGalleryGrid(sortedData, readGalleries.length); } /** * Render empty state when no read manga */ renderEmptyState() { const content = ` <div class="container"> <h1>Read Manga</h1> <div class="empty-state" style="text-align: center; padding: 60px 20px;"> <i class="fas fa-book" style="font-size: 64px; color: #666; margin-bottom: 20px;"></i> <h2 style="color: #666; margin-bottom: 10px;">No Read Manga Yet</h2> <p style="color: #999; margin-bottom: 30px;"> Start reading manga and they'll appear here automatically! </p> <a href="/" class="btn btn-primary"> <i class="fas fa-home"></i> Browse Manga </a> </div> </div> `; this.replacePageContent(content); } /** * Fetch gallery data for read galleries */ async fetchGalleryData(galleryIds, limit) { const galleryData = []; // Try to get cached data from localStorage first const cachedData = await GM.getValue('readGalleriesCache', {}); // Get the user-configured limit or provided limit const baseLimit = typeof limit === 'number' ? limit : await GM.getValue('maxReadMangaDisplay', 100); // Reverse the array to get the most recent reads first, then slice const recentGalleryIds = galleryIds.slice().reverse(); for (const galleryId of recentGalleryIds.slice(0, baseLimit)) { // Use configurable/incremental limit let galleryInfo = cachedData[galleryId]; if (!galleryInfo) { // Create basic data structure with gallery ID galleryInfo = { id: galleryId, title: `Gallery ${galleryId}`, thumbnail: null, pages: 'Unknown', tags: [], url: `/g/${galleryId}/`, cached: false }; // Try multiple sources for gallery data await this.tryFetchGalleryInfo(galleryInfo); } galleryData.push(galleryInfo); } // Cache any newly found data const updatedCache = { ...cachedData }; galleryData.forEach(gallery => { if (gallery.cached && !cachedData[gallery.id]) { updatedCache[gallery.id] = gallery; } }); await GM.setValue('readGalleriesCache', updatedCache); return galleryData; } /** * Sort gallery data based on selected sort order */ async sortGalleryData(galleryData) { const sortOrder = await GM.getValue('readMangaSortOrder', 'recent'); const readGalleries = await GM.getValue('readGalleries', []); // Create a map of gallery ID to its position in the readGalleries array (for recent/oldest sorting) const readOrderMap = {}; readGalleries.forEach((id, index) => { readOrderMap[id] = index; }); switch (sortOrder) { case 'recent': // Most recently read first (reverse order of readGalleries array) return galleryData.sort((a, b) => { const aIndex = readOrderMap[a.id] ?? 999999; const bIndex = readOrderMap[b.id] ?? 999999; return bIndex - aIndex; // Reverse order for most recent first }); case 'oldest': // Oldest read first (original order of readGalleries array) return galleryData.sort((a, b) => { const aIndex = readOrderMap[a.id] ?? 999999; const bIndex = readOrderMap[b.id] ?? 999999; return aIndex - bIndex; // Original order for oldest first }); case 'id-asc': // ID ascending (lowest ID first) return galleryData.sort((a, b) => { const aId = parseInt(a.id) || 0; const bId = parseInt(b.id) || 0; return aId - bId; }); case 'id-desc': // ID descending (highest ID first) return galleryData.sort((a, b) => { const aId = parseInt(a.id) || 0; const bId = parseInt(b.id) || 0; return bId - aId; }); default: return galleryData; } } /** * Try to fetch gallery information from multiple sources */ async tryFetchGalleryInfo(galleryInfo) { const galleryId = galleryInfo.id; // Method 1: Check if we're currently on this gallery page const galleryMatch = window.location.pathname.match(/\/g\/(\d+)\//); if (galleryMatch && galleryMatch[1] === galleryId) { // Get title from h1 or h2 const titleElement = document.querySelector('h1, h2'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } // Get cover image from #cover const coverElement = document.querySelector('#cover img'); if (coverElement && coverElement.src) { galleryInfo.thumbnail = coverElement.src; } // Get page count const pageNumberButton = document.querySelector('.page-number.btn.btn-unstyled .num-pages'); if (pageNumberButton) { galleryInfo.pages = pageNumberButton.textContent.trim(); } galleryInfo.cached = true; return; } // Method 2: Check current page for gallery listings const existingGallery = document.querySelector(`[data-gallery-id="${galleryId}"], .gallery a[href*="/g/${galleryId}/"]`); if (existingGallery) { const galleryElement = existingGallery.closest('.gallery') || existingGallery; const titleElement = galleryElement.querySelector('.caption'); const imgElement = galleryElement.querySelector('img'); if (titleElement) { galleryInfo.title = titleElement.textContent.trim(); } if (imgElement && imgElement.src) { galleryInfo.thumbnail = imgElement.src; } galleryInfo.cached = true; return; } // Method 3: Try to construct thumbnail URL from gallery ID // nhentai thumbnail pattern: https://t.nhentai.net/galleries/{id}/thumb.jpg const thumbnailUrl = `https://t.nhentai.net/galleries/${galleryId}/thumb.jpg`; // Test if the thumbnail exists try { const response = await fetch(thumbnailUrl, { method: 'HEAD' }); if (response.ok) { galleryInfo.thumbnail = thumbnailUrl; galleryInfo.cached = true; } } catch (error) { // Thumbnail doesn't exist or network error, leave as null console.log(`Thumbnail not found for gallery ${galleryId}`); } } /** * Render gallery grid for read manga */ async renderGalleryGrid(galleryData, totalAvailable) { const totalCount = galleryData.length; const showMoreButtonHtml = totalCount < totalAvailable ? `<button id="show-more-read" class="btn btn-primary" style="margin-left: 10px;"><i class="fas fa-plus"></i> Show More</button>` : ''; const content = ` <div class="container" style="min-height: 100vh; padding-bottom: 50px;"> <h1>Read Manga <span class="nobold">(${totalAvailable})</span></h1> <div class="read-manga-controls" style="margin: 20px 0; padding: 15px; background: rgba(0,0,0,0.1); border-radius: 5px;"> <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;"> <div> <button id="clear-all-read" class="btn btn-danger" style="margin-right: 10px;"> <i class="fas fa-trash"></i> Clear All Read </button> <button id="export-read-list" class="btn btn-secondary"> <i class="fas fa-download"></i> Export List </button> </div> <div> <select id="read-sort" class="form-control" style="width: auto; display: inline-block;"> <option value="recent">Recently Read</option> <option value="oldest">Oldest First</option> <option value="id-asc">ID Ascending</option> <option value="id-desc">ID Descending</option> </select> </div> </div> </div> <div class="gallery-grid read-manga-gallery-grid"> ${galleryData.map(gallery => this.createGalleryCard(gallery)).join('')} </div> <div class="pagination-info" style="text-align: center; margin: 30px 0; color: #666;"> Showing ${totalCount} of ${totalAvailable} read manga </div> <div class="read-manga-actions" style="text-align: center; margin: 20px 0 40px;"> ${showMoreButtonHtml} </div> </div> `; this.replacePageContent(content); await this.addReadPageEventListeners(); } /** * Create gallery card HTML */ createGalleryCard(gallery) { // Handle thumbnail display const thumbnailHtml = gallery.thumbnail ? `<img src="${gallery.thumbnail}" alt="${gallery.title}" loading="lazy" style="width: 100%; height: 300px; object-fit: cover; display: block;" onload="const placeholder = this.nextElementSibling; placeholder.style.display='none'; Array.from(placeholder.children).forEach(child => child.style.display='none');" onerror="this.style.display='none'; const placeholder = this.nextElementSibling; placeholder.style.display='flex'; Array.from(placeholder.children).forEach(child => child.style.display='block');">` : ''; const noImageHtml = ` <div class="no-image-placeholder" style="display: ${gallery.thumbnail ? 'none !important' : 'flex'}; width: 100%; height: 300px; background: linear-gradient(135deg, #333 0%, #555 100%); align-items: center; justify-content: center; flex-direction: column; color: #999;"> <i class="fas fa-book" style="font-size: 48px; margin-bottom: 10px; opacity: 0.5; display: ${gallery.thumbnail ? 'none !important' : 'block'};"></i> <span style="font-size: 14px; font-weight: 500; display: ${gallery.thumbnail ? 'none !important' : 'block'};">Gallery ${gallery.id}</span> <span style="font-size: 12px; opacity: 0.7; display: ${gallery.thumbnail ? 'none !important' : 'block'};">No Preview Available</span> </div> `; return ` <div class="gallery read-gallery read-manga-gallery" data-gallery-id="${gallery.id}" style="position: relative; border-radius: 3px; overflow: visible; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); transition: all 0.3s ease; background: #252525; height: auto; min-height: 350px; padding-bottom: 50px;"> <a href="${gallery.url}" class="cover" style="position: relative; display: block; height: 300px; overflow: hidden; border-radius: 3px 3px 0 0;"> ${thumbnailHtml} ${noImageHtml} <!-- Remove from read button (no read badge on read manga page) --> <button class="remove-read-btn" data-gallery-id="${gallery.id}" title="Remove from read list" style="position: absolute; top: 5px; right: 5px; width: 28px; height: 28px; background: rgba(244, 67, 54, 0.9); border: none; border-radius: 50%; color: white; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; z-index: 100;"> <i class="fas fa-times"></i> </button> </a> <!-- Caption positioned below the image, not overlapping --> <div class="caption read-manga-caption" style="padding: 8px 10px; background: #1e1e1e; color: white; font-size: 12px; line-height: 1.3; min-height: 40px; max-height: 50px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; position: absolute; bottom: 0; left: 0; right: 0; border-radius: 0 0 3px 3px;">${gallery.title}</div> </div> `; } /** * Add event listeners for read page controls */ async addReadPageEventListeners() { // Clear all read button const clearAllBtn = document.getElementById('clear-all-read'); if (clearAllBtn) { clearAllBtn.addEventListener('click', async () => { if (confirm('Are you sure you want to clear all read manga? This action cannot be undone.')) { await GM.setValue('readGalleries', []); this.renderEmptyState(); // Show notification this.showNotification('All read manga cleared!', 'success'); } }); } // Export read list button const exportBtn = document.getElementById('export-read-list'); if (exportBtn) { exportBtn.addEventListener('click', async () => { const readGalleries = await GM.getValue('readGalleries', []); const exportData = { exported: new Date().toISOString(), version: '9.0.0', readGalleries: readGalleries }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `nhentai-read-list-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); this.showNotification('Read list exported!', 'success'); }); } // Remove individual read items const removeButtons = document.querySelectorAll('.remove-read-btn'); removeButtons.forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const galleryId = btn.getAttribute('data-gallery-id'); if (confirm('Remove this manga from your read list?')) { await this.removeFromReadList(galleryId); // Remove the gallery card from the page const galleryCard = btn.closest('.gallery'); if (galleryCard) { galleryCard.style.opacity = '0'; galleryCard.style.transform = 'scale(0.8)'; setTimeout(() => galleryCard.remove(), 300); } this.showNotification('Removed from read list!', 'success'); } }); }); // Sort functionality const sortSelect = document.getElementById('read-sort'); if (sortSelect) { // Set the current sort value from storage const currentSort = await GM.getValue('readMangaSortOrder', 'recent'); sortSelect.value = currentSort; sortSelect.addEventListener('change', async () => { // Save the selected sort order await GM.setValue('readMangaSortOrder', sortSelect.value); // Re-render the page with new sorting this.renderReadMangaPage(); }); } // Show More functionality const showMoreBtn = document.getElementById('show-more-read'); if (showMoreBtn) { showMoreBtn.addEventListener('click', async () => { const base = this.currentDisplayLimit ?? await GM.getValue('maxReadMangaDisplay', 100); this.currentDisplayLimit = base + this.showMoreStep; this.renderReadMangaPage(); }); } } /** * Remove gallery from read list */ async removeFromReadList(galleryId) { const readGalleries = await GM.getValue('readGalleries', []); const updatedList = readGalleries.filter(id => id !== galleryId); await GM.setValue('readGalleries', updatedList); } /** * Replace page content */ replacePageContent(content) { // Find the main content area const mainContent = document.querySelector('#content') || document.body; // Wrap content in read-manga-page class for styling const wrappedContent = `<div class="read-manga-page">${content}</div>`; mainContent.innerHTML = wrappedContent; // Update page title document.title = 'Read Manga - nhentai'; // Add body class for additional styling document.body.classList.add('read-manga-active'); // Add specific CSS for read manga page this.addReadMangaPageCSS(); } /** * Add CSS styling specific to read manga page */ addReadMangaPageCSS() { const css = ` /* Read Manga Page Container */ .read-manga-page { min-height: 100vh; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); color: #fff; position: relative; } .read-manga-page::before { content: ''; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.1) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(120, 219, 226, 0.1) 0%, transparent 50%); pointer-events: none; z-index: 0; } .read-manga-page .container { position: relative; z-index: 1; } /* Enhanced Header Styling */ .read-manga-page h1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-size: 2.5rem; font-weight: 700; text-align: center; margin: 30px 0; text-shadow: 0 2px 4px rgba(0,0,0,0.3); } /* Gallery Grid Layout for Read Manga - Default Desktop */ .read-manga-page .gallery-grid, .read-manga-page .read-manga-gallery-grid { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important; gap: 30px !important; padding: 30px 20px !important; margin: 0 auto !important; max-width: 1400px !important; min-height: 400px !important; } /* Individual Read Manga Gallery Item */ .read-manga-gallery { position: relative !important; border-radius: 12px !important; overflow: hidden !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important; transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; background: linear-gradient(145deg, #2a2a2a 0%, #1e1e1e 100%) !important; height: auto !important; min-height: 380px !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; display: flex !important; flex-direction: column !important; } .read-manga-gallery:hover { transform: translateY(-8px) scale(1.02) !important; box-shadow: 0 20px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1) !important; border-color: rgba(102, 126, 234, 0.3) !important; } .read-manga-gallery::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%); opacity: 0; transition: opacity 0.3s ease; z-index: 1; pointer-events: none; } .read-manga-gallery[data-language-flagged="1"]::before { display: none !important; } .read-manga-gallery[data-language-flagged="1"] .caption::before, .read-manga-gallery[data-language-flagged="1"] .read-manga-caption::before { content: none !important; display: none !important; background: none !important; } .read-manga-gallery[data-language-flagged="1"] .language-flag::before, .read-manga-gallery[data-language-flagged="1"] .overlayFlag::before { content: none !important; display: none !important; } .read-manga-gallery:hover::before { opacity: 1; } /* Gallery Cover Image for Read Manga */ .read-manga-gallery .cover { position: relative !important; display: block !important; height: 320px !important; overflow: hidden !important; border-radius: 12px 12px 0 0 !important; z-index: 2 !important; flex-shrink: 0 !important; } .read-manga-gallery .cover::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(180deg, transparent 0%, transparent 60%, rgba(0,0,0,0.8) 100%); z-index: 3; pointer-events: none; } .read-manga-gallery .cover img { width: 100% !important; height: 100% !important; object-fit: cover !important; transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; filter: brightness(0.9) contrast(1.1) !important; } .read-manga-gallery:hover .cover img { transform: scale(1.08) !important; filter: brightness(1) contrast(1.2) !important; } /* Caption positioning below image, not overlapping */ .read-manga-caption { padding: 12px 15px !important; background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%) !important; color: #f0f0f0 !important; font-size: 13px !important; line-height: 1.4 !important; min-height: 50px !important; max-height: 60px !important; overflow: hidden !important; text-overflow: ellipsis !important; display: -webkit-box !important; -webkit-line-clamp: 2 !important; -webkit-box-orient: vertical !important; border-radius: 0 0 12px 12px !important; z-index: 5 !important; font-weight: 500 !important; border-top: 1px solid rgba(255, 255, 255, 0.1) !important; backdrop-filter: blur(10px) !important; flex-shrink: 0 !important; margin-top: auto !important; } .read-manga-gallery:hover .read-manga-caption { background: linear-gradient(135deg, #333 0%, #222 100%) !important; color: #fff !important; } /* Remove from Read List Button */ .read-manga-gallery .remove-read-btn { position: absolute !important; top: 12px; width: 32px !important; height: 32px !important; background: linear-gradient(135deg, rgba(244, 67, 54, 0.9) 0%, rgba(211, 47, 47, 0.9) 100%) !important; border: none !important; border-radius: 50% !important; color: white !important; font-size: 14px !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) !important; z-index: 11 !important; box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3) !important; backdrop-filter: blur(10px) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; opacity: 0.8 !important; } .read-manga-gallery .remove-read-btn:hover { background: linear-gradient(135deg, rgba(244, 67, 54, 1) 0%, rgba(211, 47, 47, 1) 100%) !important; transform: scale(1.15) !important; box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4) !important; opacity: 1 !important; } .read-manga-gallery .remove-read-btn:active { transform: scale(0.95) !important; } /* Read Badge */ .read-manga-gallery .read-badge { position: absolute !important; top: 5px !important; left: 5px !important; background: rgba(76, 175, 80, 0.9) !important; color: white !important; padding: 4px 8px !important; border-radius: 3px !important; font-size: 11px !important; font-weight: bold !important; z-index: 10 !important; } /* No Image Placeholder */ .read-manga-gallery .no-image-placeholder { width: 100% !important; height: 300px !important; background: linear-gradient(135deg, #333 0%, #555 100%) !important; display: flex !important; align-items: center !important; justify-content: center !important; flex-direction: column !important; color: #999 !important; transition: all 0.3s ease !important; } .read-manga-gallery:hover .no-image-placeholder { background: linear-gradient(135deg, #444 0%, #666 100%) !important; } /* Responsive Design for Read Manga */ @media (max-width: 768px) { .read-manga-page .gallery-grid, .read-manga-page .read-manga-gallery-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)) !important; gap: 12px !important; padding: 15px 10px !important; max-width: 100% !important; } .read-manga-gallery { min-height: 240px !important; max-width: 200px !important; margin: 0 auto !important; display: flex !important; flex-direction: column !important; } .read-manga-gallery .cover { height: 180px !important; flex-shrink: 0 !important; } .read-manga-caption { font-size: 11px !important; padding: 8px 10px !important; min-height: 40px !important; max-height: 50px !important; -webkit-line-clamp: 2 !important; line-height: 1.3 !important; flex-shrink: 0 !important; margin-top: auto !important; } .read-manga-gallery .remove-read-btn { width: 26px !important; height: 26px !important; font-size: 12px !important; top: 8px; right: 8px !important; } .read-manga-gallery .no-image-placeholder { height: 180px !important; } .read-manga-gallery .no-image-placeholder i { font-size: 36px !important; margin-bottom: 8px !important; } .read-manga-gallery .no-image-placeholder span { font-size: 11px !important; } /* Mobile Controls */ .read-manga-controls { padding: 15px !important; margin: 20px 10px !important; border-radius: 12px !important; } .read-manga-controls > div { flex-direction: column !important; gap: 15px !important; } .read-manga-controls .btn { min-width: 100% !important; padding: 10px 16px !important; font-size: 13px !important; } .read-manga-controls .form-control { min-width: 100% !important; padding: 10px 14px !important; font-size: 13px !important; } } /* Extra small mobile screens */ @media (max-width: 480px) { .read-manga-page .gallery-grid, .read-manga-page .read-manga-gallery-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) !important; gap: 10px !important; padding: 12px 8px !important; } .read-manga-gallery { min-height: 210px !important; max-width: 170px !important; display: flex !important; flex-direction: column !important; } .read-manga-gallery .cover { height: 160px !important; flex-shrink: 0 !important; } .read-manga-caption { font-size: 10px !important; padding: 6px 8px !important; min-height: 50px !important; max-height: 45px !important; line-height: 1.2 !important; flex-shrink: 0 !important; margin-top: auto !important; } .read-manga-gallery .remove-read-btn { width: 24px !important; height: 24px !important; font-size: 11px !important; top: 6px; right: 6px !important; } .read-manga-gallery .no-image-placeholder { height: 160px !important; } .read-manga-gallery .no-image-placeholder i { font-size: 32px !important; } .read-manga-gallery .no-image-placeholder span { font-size: 10px !important; } /* Extra small mobile controls */ .read-manga-controls { padding: 12px !important; margin: 15px 8px !important; border-radius: 10px !important; } .read-manga-controls .btn { padding: 8px 12px !important; font-size: 12px !important; } .read-manga-controls .form-control { padding: 8px 12px !important; font-size: 12px !important; } } @media (min-width: 1400px) { .read-manga-page .gallery-grid, .read-manga-page .read-manga-gallery-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) !important; gap: 30px !important; } .read-manga-gallery { min-height: 400px !important; } .read-manga-gallery .cover { height: 350px !important; } .read-manga-gallery .no-image-placeholder { height: 350px !important; } } /* Override conflicting gallery grid styles for read manga page */ .read-manga-page .gallery-grid.read-manga-gallery-grid, .read-manga-page .gallery-grid { display: grid !important; flex-wrap: unset !important; justify-content: unset !important; align-items: unset !important; } .read-manga-page .gallery-grid.read-manga-gallery-grid .gallery, .read-manga-page .gallery-grid .gallery { width: unset !important; margin: unset !important; flex: unset !important; } /* Ensure proper container height */ .read-manga-page .container { min-height: 100vh !important; padding-bottom: 50px !important; } /* Fix any potential z-index issues */ .read-manga-page { position: relative; z-index: 1; } /* Hide mark-as-read buttons on read manga page */ .read-manga-page .mark-as-read-btn, .read-manga-gallery .mark-as-read-btn, body.read-manga-active .mark-as-read-btn { display: none !important; } /* Enhanced Controls Styling */ .read-manga-controls { background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%) !important; border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 16px !important; backdrop-filter: blur(20px) !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important; padding: 20px !important; margin: 30px 0 !important; } .read-manga-controls .btn { background: linear-gradient(135deg, #4a4a4a 0%, #333 100%) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; border-radius: 10px !important; color: #f0f0f0 !important; font-weight: 500 !important; padding: 12px 20px !important; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; min-width: 140px !important; line-height: 0px; } .read-manga-controls .btn:hover { background: linear-gradient(135deg, #555 0%, #444 100%) !important; border-color: rgba(255, 255, 255, 0.3) !important; transform: translateY(-2px) !important; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important; color: #fff !important; } .read-manga-controls .btn:active { transform: translateY(0) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; } .read-manga-controls .btn-danger { background: linear-gradient(135deg, #e53935 0%, #c62828 100%) !important; border-color: rgba(255, 255, 255, 0.2) !important; } .read-manga-controls .btn-danger:hover { background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%) !important; box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4) !important; } .read-manga-controls .form-control { background: linear-gradient(135deg, #4a4a4a 0%, #333 100%) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; border-radius: 10px !important; color: #f0f0f0 !important; padding: 12px 16px !important; font-weight: 500 !important; min-width: 160px !important; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; } .read-manga-controls .form-control:hover, .read-manga-controls .form-control:focus { background: linear-gradient(135deg, #555 0%, #444 100%) !important; border-color: rgba(255, 255, 255, 0.3) !important; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important; outline: none !important; } .read-manga-controls .form-control option { background: #333 !important; color: #f0f0f0 !important; } /* Enhanced Page Header */ .read-manga-page h1 { background: linear-gradient(135deg, #fff 0%, #e0e0e0 100%) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; background-clip: text !important; font-size: 2.5rem !important; font-weight: 700 !important; text-align: center !important; margin: 30px 0 !important; text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important; letter-spacing: 1px !important; } .read-manga-page h1 .nobold { font-weight: 400 !important; opacity: 0.8 !important; } /* Enhanced pagination info */ .read-manga-page .pagination-info { background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 12px !important; padding: 15px 20px !important; color: #ccc !important; font-weight: 500 !important; backdrop-filter: blur(10px) !important; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2) !important; } `; GM.addStyle(css); } /** * Show notification */ showNotification(message, type = 'success') { const notification = document.createElement('div'); const backgroundColor = type === 'error' ? 'rgba(244, 67, 54, 0.9)' : 'rgba(46, 125, 50, 0.9)'; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${backgroundColor}; color: white; padding: 12px 20px; border-radius: 8px; z-index: 10000; font-size: 14px; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,0.3); `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '0'; notification.style.transition = 'opacity 0.3s ease'; setTimeout(() => notification.remove(), 300); }, 3000); } } // Initialize Read Manga Page System let readMangaPageSystem; async function initReadMangaPageSystem() { readMangaPageSystem = new ReadMangaPageSystem(); // Make it available globally for the navigation button window.readMangaPageSystem = readMangaPageSystem; } // Initialize on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initReadMangaPageSystem); } else { initReadMangaPageSystem(); } //------------------------ **Read Manga Page System** ------------------ //------------------------ **Enhanced CSS Styling and Visual Enhancements** ------------------ /** * Enhanced CSS Styling System * Provides comprehensive styling for all new UI elements */ let quickNutPageSystem; class QuickNutPageSystem { constructor() { this.pageUrl = '/quick-nut/'; this.maxPerFavorite = 20; this.thumbCacheKey = 'quickNutThumbCache'; this.seenIdsKey = 'quickNutSeenIds'; this.targetTotal = 60; this.maxPagesPerFavorite = 5; this.fallbackFillEnabled = true; this.init(); } async init() { await this.addNavigationLink(); this.handlePageRouting(); } async addNavigationLink() { const enabled = await GM.getValue('quickNutPageEnabled', true); if (!enabled) return; if (document.querySelector('a[href="/quick-nut/"]')) return; const btnHtml = ` <li> <a href="/quick-nut/"> <i class="fas fa-bolt"></i> Quick Nut </a> </li> `; const btn = $(btnHtml); const dropdownMenu = $('ul.dropdown-menu'); dropdownMenu.append(btn); const menu = $('ul.menu.left'); menu.append(btn); btn.find('a').on('click', (e) => { e.preventDefault(); this.navigateToQuickNutPage(); }); setTimeout(() => { if (typeof updateMenuOrder === 'function') updateMenuOrder(); }, 100); } async fetchWithRetry(url, options = {}, retries = 1, delay = 10000) { const res = await fetch(url, options); if (res && res.status === 429 && retries > 0) { await new Promise(r => setTimeout(r, delay)); return this.fetchWithRetry(url, options, retries - 1, delay); } return res; } handlePageRouting() { if (window.location.pathname === this.pageUrl || window.location.hash === '#quick-nut') { this.renderQuickNutPage(); } } navigateToQuickNutPage() { history.pushState({}, 'Quick Nut - nhentai', this.pageUrl); this.renderQuickNutPage(); } async renderQuickNutPage() { const englishOnly = await GM.getValue('quickNutEnglishOnlyEnabled', true); const refreshMode = await GM.getValue('quickNutRefreshMode', 'daily'); const customMinutes = await GM.getValue('quickNutCustomMinutes', 1440); const cache = await GM.getValue('quickNutCache', { builtAt: 0, items: [] }); const shouldRebuild = this.shouldRebuild(refreshMode, customMinutes, cache && cache.builtAt ? cache.builtAt : 0); let items = Array.isArray(cache && cache.items) ? cache.items : []; const seen = await GM.getValue(this.seenIdsKey, []); const avoidSet = new Set(Array.isArray(seen) ? seen : []); const skipRead = await GM.getValue('quickNutSkipReadEnabled', true); const readList = await GM.getValue('readGalleries', []); const readSet = new Set(Array.isArray(readList) ? readList : []); console.log('[QuickNut] render start', { englishOnly, refreshMode, customMinutes, lastBuiltAt: cache && cache.builtAt, cachedCount: Array.isArray(cache && cache.items) ? cache.items.length : 0, shouldRebuild, avoidCount: avoidSet.size, skipRead, readCount: readSet.size }); if (shouldRebuild || items.length === 0) { items = await this.buildAggregatedItems(englishOnly, avoidSet, 1, readSet, !!skipRead); await GM.setValue('quickNutCache', { builtAt: Date.now(), items }); try { if (typeof taxonomyManager !== 'undefined' && typeof taxonomyManager.unload === 'function') taxonomyManager.unload(); } catch (_) {} } else { const filtered = items.filter(it => !avoidSet.has(it.id) && !(skipRead && readSet.has(it.id))); console.log('[QuickNut] render filter cached', { before: items.length, after: filtered.length }); items = filtered; } console.log('[QuickNut] render items', items.length); this.replacePageContent(this.buildPageContent(items)); this.attachHandlers(); if (window.readMangaPageSystem && typeof window.readMangaPageSystem.addReadMangaPageCSS === 'function') { window.readMangaPageSystem.addReadMangaPageCSS(); } try { await this.addSeenIds(items.map(it => it.id)); } catch (_) {} } shouldRebuild(mode, minutes, lastTS) { const now = Date.now(); if (mode === 'per_visit') return true; if (mode === 'custom') { const ms = Math.max(60000, (parseInt(minutes) || 60) * 60000); return (now - lastTS) > ms; } return (now - lastTS) > 24 * 60 * 60 * 1000; } async buildAggregatedItems(englishOnly, avoidIdsSet = null, startPage = 1, readIdsSet = null, skipReadEnabled = false) { const favorites = await GM.getValue('favoriteTagsList', []); const favs = Array.isArray(favorites) ? favorites.map(s => String(s).trim()).filter(Boolean) : []; const itemsById = new Map(); const avoidedBucket = []; let skippedReadCount = 0; console.log('[QuickNut] build start', { englishOnly, favoritesCount: favs.length, avoidCount: avoidIdsSet ? avoidIdsSet.size : 0, readCount: readIdsSet ? readIdsSet.size : 0, skipReadEnabled, startPage, maxPagesPerFavorite: this.maxPagesPerFavorite, targetTotal: this.targetTotal }); try { if (typeof taxonomyManager !== 'undefined') await taxonomyManager.ensureLoaded(); } catch (_) {} for (const name of favs) { let types = []; try { types = (typeof taxonomyManager !== 'undefined') ? await taxonomyManager.getTypesForNameAsync(name) : []; } catch (_) { types = []; } const allowed = Array.isArray(types) ? types.filter(t => ['artist','group','parody','character','tag'].includes(t)) : []; console.log('[QuickNut] classify', { name, types: allowed }); if (!allowed.length) { console.log('[QuickNut] skip unsupported types', { name }); continue; } const cleanValue = String(name).replace(/["']/g, '').trim(); for (const type of allowed) { for (let page = startPage; page <= this.maxPagesPerFavorite; page++) { const effectiveTarget = this.targetTotal + skippedReadCount; if (itemsById.size >= effectiveTarget) break; const query = `${type}:"${cleanValue}"${englishOnly ? ' language:"english"' : ''}`; const url = `https://nhentai.net/search/?q=${encodeURIComponent(query)}${page && page > 1 ? `&page=${page}` : ''}`; console.log('[QuickNut] fetch search', { url, page, name, type }); try { const res = await this.fetchWithRetry(url, { credentials: 'include' }, 1, 10000); if (!res || !res.ok) { console.log('[QuickNut] search failed', { url, ok: res && res.ok, status: res && res.status }); continue; } const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const galleries = Array.from(doc.querySelectorAll('.gallery')); console.log('[QuickNut] search results', { name, type, page, count: galleries.length }); for (let i = 0; i < galleries.length && i < this.maxPerFavorite; i++) { const effectiveTargetInner = this.targetTotal + skippedReadCount; if (itemsById.size >= effectiveTargetInner) break; const g = galleries[i]; const link = g.querySelector('a[href*="/g/"]'); const img = g.querySelector('img'); const caption = g.querySelector('.caption'); if (!link) continue; const m = link.getAttribute('href').match(/\/g\/(\d+)\//); const id = m ? m[1] : null; if (!id) { console.log('[QuickNut] skip no-id gallery'); continue; } if (itemsById.has(id)) { console.log('[QuickNut] skip duplicate id', { id }); continue; } const thumb = await this.getThumbForGallery(id, img); const title = caption ? caption.textContent.trim() : `Gallery ${id}`; const item = { id, title, thumbnail: thumb || null, url: `/g/${id}/` }; if (skipReadEnabled && readIdsSet && readIdsSet.has(id)) { skippedReadCount++; console.log('[QuickNut] skip read id', { id, skippedReadCount }); continue; } if (avoidIdsSet && avoidIdsSet.has(id)) { avoidedBucket.push(item); console.log('[QuickNut] bucket avoid id', { id }); continue; } itemsById.set(id, item); console.log('[QuickNut] add item', { id, page, type, hasThumb: !!thumb }); } } catch (e) { console.log('[QuickNut] fetch error', e); } } } } if (itemsById.size === 0 && avoidedBucket.length && this.fallbackFillEnabled) { console.log('[QuickNut] fallback fill from avoided bucket', { count: avoidedBucket.length }); for (let i = 0; i < avoidedBucket.length && itemsById.size < Math.min(this.targetTotal, 30); i++) { const it = avoidedBucket[i]; if (!itemsById.has(it.id)) itemsById.set(it.id, it); } } const built = Array.from(itemsById.values()); console.log('[QuickNut] build done', { total: built.length, skippedReadCount }); return built; } async getThumbForGallery(id, imgEl) { const candidate = this.getThumbFromSearchImg(imgEl); console.log('[QuickNut] thumb candidate', { id, candidate, isPlaceholder: this.isPlaceholderSrc(candidate) }); if (candidate && !this.isPlaceholderSrc(candidate)) return candidate; const cached = await this.getCachedThumbUrl(id); console.log('[QuickNut] thumb cached?', { id, cached: !!cached }); if (cached) return cached; const firstPage = await this.fetchFirstPageImageUrl(id); console.log('[QuickNut] thumb firstPage?', { id, firstPage: !!firstPage }); if (firstPage) { await this.saveThumbCache(id, firstPage); return firstPage; } const finalUrl = candidate || null; console.log('[QuickNut] thumb final', { id, url: finalUrl }); return finalUrl; } getThumbFromSearchImg(imgEl) { if (!imgEl) return null; const dataSrc = imgEl.getAttribute('data-src'); if (dataSrc && !this.isPlaceholderSrc(dataSrc)) return dataSrc; const src = imgEl.getAttribute('src'); if (src && !this.isPlaceholderSrc(src)) return src; return null; } isPlaceholderSrc(url) { return !url || url.startsWith('data:image/gif;base64'); } async fetchFirstPageImageUrl(id) { try { const firstPageUrl = `https://nhentai.net/g/${id}/1/`; console.log('[QuickNut] fetch first page', { id, url: firstPageUrl }); const res = await this.fetchWithRetry(firstPageUrl, { credentials: 'include' }, 1, 10000); if (!res || !res.ok) { console.log('[QuickNut] first page failed', { id, ok: res && res.ok, status: res && res.status }); return null; } const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const img = doc.querySelector('#image-container img'); const src = img && img.getAttribute('src') ? img.getAttribute('src') : null; console.log('[QuickNut] first page img', { id, src }); return src; } catch (_) { return null; } } async getCachedThumbUrl(id) { try { const cache = await GM.getValue(this.thumbCacheKey, {}); return cache && cache[id] ? cache[id].url : null; } catch (_) { return null; } } async saveThumbCache(id, url) { try { const cache = await GM.getValue(this.thumbCacheKey, {}); cache[id] = { url, ts: Date.now() }; await GM.setValue(this.thumbCacheKey, cache); console.log('[QuickNut] thumb cached', { id, url }); } catch (_) {} } async clearThumbCache() { await GM.setValue(this.thumbCacheKey, {}); console.log('[QuickNut] thumb cache cleared'); } buildPageContent(items) { const content = ` <div class="container" style="min-height: 100vh; padding-bottom: 50px;"> <div class="read-manga-controls" style="display:flex; gap:8px; align-items:center; justify-content:flex-end; margin-top:10px;"> <button id="quick-nut-refresh" class="btn btn-secondary">Refresh</button> </div> <h1>Quick Nut <span class="nobold">(${items.length})</span></h1> <div class="gallery-grid read-manga-gallery-grid"> ${items.map(g => this.createGalleryCard(g)).join('')} </div> </div> `; return content; } createGalleryCard(gallery) { const thumbnailHtml = gallery.thumbnail ? `<img src="${gallery.thumbnail}" alt="${gallery.title}" loading="lazy" style="width: 100%; height: 300px; object-fit: cover; display: block;" onload="const placeholder = this.nextElementSibling; placeholder.style.display='none'; Array.from(placeholder.children).forEach(child => child.style.display='none');" onerror="this.style.display='none'; const placeholder = this.nextElementSibling; placeholder.style.display='flex'; Array.from(placeholder.children).forEach(child => child.style.display='block');">` : ''; const noImageHtml = ` <div class="no-image-placeholder" style="display: ${gallery.thumbnail ? 'none !important' : 'flex'}; width: 100%; height: 300px; background: linear-gradient(135deg, #333 0%, #555 100%); align-items: center; justify-content: center; flex-direction: column; color: #999;"> <i class="fas fa-book" style="font-size: 48px; margin-bottom: 10px; opacity: 0.5; display: ${gallery.thumbnail ? 'none !important' : 'block'};"></i> <span style="font-size: 14px; font-weight: 500; display: ${gallery.thumbnail ? 'none !important' : 'block'};">Gallery ${gallery.id}</span> <span style="font-size: 12px; opacity: 0.7; display: ${gallery.thumbnail ? 'none !important' : 'block'};">No Preview Available</span> </div> `; return ` <div class="gallery read-gallery read-manga-gallery" data-gallery-id="${gallery.id}" style="position: relative; border-radius: 3px; overflow: visible; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); transition: all 0.3s ease; background: #252525; height: auto; min-height: 350px; padding-bottom: 50px;"> <a href="${gallery.url}" class="cover" style="position: relative; display: block; height: 300px; overflow: hidden; border-radius: 3px 3px 0 0;"> ${thumbnailHtml} ${noImageHtml} </a> <div class="caption read-manga-caption" style="padding: 8px 10px; background: #1e1e1e; color: white; font-size: 12px; line-height: 1.3; min-height: 40px; max-height: 50px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; position: absolute; bottom: 0; left: 0; right: 0; border-radius: 0 0 3px 3px;">${gallery.title}</div> </div> `; } replacePageContent(content) { const mainContent = document.querySelector('#content') || document.body; const wrappedContent = `<div class="read-manga-page">${content}</div>`; mainContent.innerHTML = wrappedContent; document.title = 'Quick Nut - nhentai'; document.body.classList.add('read-manga-active'); } attachHandlers() { const btn = document.getElementById('quick-nut-refresh'); if (btn) { btn.addEventListener('click', async () => { const currentIds = Array.from(document.querySelectorAll('.read-manga-gallery[data-gallery-id]')).map(el => el.getAttribute('data-gallery-id')); console.log('[QuickNut] refresh clicked', { currentCount: currentIds.length }); await this.addSeenIds(currentIds); await this.refreshAvoidingSeen(); }); } } async addSeenIds(ids) { try { const list = await GM.getValue(this.seenIdsKey, []); const set = new Set(Array.isArray(list) ? list : []); for (const id of ids) set.add(id); const limited = Array.from(set).slice(-1000); await GM.setValue(this.seenIdsKey, limited); console.log('[QuickNut] addSeenIds', { addCount: ids.length, before: Array.isArray(list) ? list.length : 0, after: limited.length }); } catch (_) {} } async refreshAvoidingSeen() { try { const englishOnly = await GM.getValue('quickNutEnglishOnlyEnabled', true); const seen = await GM.getValue(this.seenIdsKey, []); const avoidSet = new Set(Array.isArray(seen) ? seen : []); const skipRead = await GM.getValue('quickNutSkipReadEnabled', true); const readList = await GM.getValue('readGalleries', []); const readSet = new Set(Array.isArray(readList) ? readList : []); console.log('[QuickNut] refreshAvoidingSeen', { englishOnly, avoidCount: avoidSet.size }); await GM.setValue('quickNutCache', { builtAt: 0, items: [] }); await this.clearThumbCache(); const items = await this.buildAggregatedItems(englishOnly, avoidSet, 2, readSet, !!skipRead); await GM.setValue('quickNutCache', { builtAt: Date.now(), items }); try { if (typeof taxonomyManager !== 'undefined' && typeof taxonomyManager.unload === 'function') taxonomyManager.unload(); } catch (_) {} this.replacePageContent(this.buildPageContent(items)); this.attachHandlers(); if (window.readMangaPageSystem && typeof window.readMangaPageSystem.addReadMangaPageCSS === 'function') { window.readMangaPageSystem.addReadMangaPageCSS(); } console.log('[QuickNut] refresh built', { count: items.length }); } catch (e) {} } } async function initQuickNutPageSystem() { quickNutPageSystem = new QuickNutPageSystem(); window.quickNutPageSystem = quickNutPageSystem; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initQuickNutPageSystem); } else { initQuickNutPageSystem(); } function addEnhancedCSS() { const css = ` /* Global Enhancements */ .gallery { position: relative; transition: all 0.3s ease; } /* Commented out as this was causing captions to appear under galleries .gallery:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } */ /* Gallery Grid Layout */ .gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 25px; padding: 20px 10px; margin: 0 auto; max-width: 1200px; } @media (max-width: 768px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(115px, 1fr)); gap: 15px; padding: 15px 5px; } } @media (min-width: 1400px) { .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 30px; } } /* Advanced Search Buttons */ .advanced-search-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; padding: 8px 16px; border-radius: 20px; text-decoration: none; font-size: 12px; font-weight: 600; transition: all 0.3s ease; display: inline-flex; align-items: center; margin: 2px; } .advanced-search-btn:hover { background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); color: white; text-decoration: none; } .advanced-search-btn i { margin-right: 5px; } /* Favorite Tag Search Container */ #favorite-tag-searches { background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2); border-radius: 8px; padding: 15px; margin: 15px 0; } #favorite-tag-searches h4 { color: #667eea; margin-bottom: 10px; font-size: 14px; font-weight: 600; } /* Enhanced Tag Warning Badges */ .tag-warning-badge { position: absolute; bottom: 5px; left: 5px; padding: 3px 8px; border-radius: 12px; font-size: 10px; font-weight: bold; color: white; z-index: 10; pointer-events: none; text-transform: uppercase; box-shadow: 0 2px 4px rgba(0,0,0,0.3); backdrop-filter: blur(4px); transition: all 0.3s ease; } .tag-warning-badge.blacklist { background: linear-gradient(135deg, #ff5252 0%, #f44336 100%); border: 1px solid rgba(255, 255, 255, 0.2); } .tag-warning-badge.warning { background: linear-gradient(135deg, #ffb74d 0%, #ff9800 100%); border: 1px solid rgba(255, 255, 255, 0.2); } .tag-warning-badge.favorite { background: linear-gradient(135deg, #42a5f5 0%, #2196f3 100%); border: 1px solid rgba(255, 255, 255, 0.2); } /* Badge stacking with improved spacing */ .gallery .tag-warning-badge:nth-of-type(1) { bottom: 5px; } .gallery .tag-warning-badge:nth-of-type(2) { bottom: 28px; } .gallery .tag-warning-badge:nth-of-type(3) { bottom: 51px; } /* Enhanced Mark as Read Button */ .mark-as-read-btn { position: absolute; top: 5px; right: 5px; width: 28px; height: 28px; background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.9) 100%); border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; color: #fff; cursor: pointer; font-size: 14px; z-index: 10; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; backdrop-filter: blur(4px); } .mark-as-read-btn:hover { background: linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 1) 100%); transform: scale(1.1); border-color: rgba(255, 255, 255, 0.4); } .mark-as-read-btn.read { background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%); border-color: rgba(255, 255, 255, 0.3); } .mark-as-read-btn.read:hover { background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); } /* Enhanced Read Badge */ .read-badge { position: absolute; top: 5px; left: 5px; background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%); color: white; padding: 4px 8px; border-radius: 12px; font-size: 10px; font-weight: bold; z-index: 10; pointer-events: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3); backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.2); } /* Language Flags Enhancement */ .language-flag { border-radius: 2px; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border: 1px solid rgba(255, 255, 255, 0.2); } /* Gallery Page Tag Enhancements */ .tag-container .tag { transition: all 0.3s ease; position: relative; } .tag-container .tag.blacklist-tag { background: linear-gradient(135deg, rgba(244, 67, 54, 0.2) 0%, rgba(244, 67, 54, 0.3) 100%) !important; border: 1px solid #f44336 !important; color: #f44336 !important; box-shadow: 0 2px 4px rgba(244, 67, 54, 0.2); } .tag-container .tag.warning-tag { background: linear-gradient(135deg, rgba(255, 152, 0, 0.2) 0%, rgba(255, 152, 0, 0.3) 100%) !important; border: 1px solid #ff9800 !important; color: #ff9800 !important; box-shadow: 0 2px 4px rgba(255, 152, 0, 0.2); } .tag-container .tag.favorite-tag { background: linear-gradient(135deg, rgba(33, 150, 243, 0.2) 0%, rgba(33, 150, 243, 0.3) 100%) !important; border: 1px solid #2196f3 !important; color: #2196f3 !important; box-shadow: 0 2px 4px rgba(33, 150, 243, 0.2); } /* Enhanced Star Button */ .tag-star-btn { margin-left: 8px; cursor: pointer; color: #666; transition: all 0.3s ease; font-size: 16px; display: inline-block; transform: scale(1); } .tag-star-btn:hover { color: #2196f3; transform: scale(1.2); text-shadow: 0 0 8px rgba(33, 150, 243, 0.5); } .tag-star-btn.favorited { color: #2196f3; text-shadow: 0 0 8px rgba(33, 150, 243, 0.3); } /* Opacity and Fade Enhancements */ .gallery.read-gallery .cover img, .gallery.read-gallery .cover .caption { transition: opacity 0.4s ease, filter 0.4s ease; filter: grayscale(0.3); } .gallery.read-gallery:hover .cover img, .gallery.read-gallery:hover .cover .caption { filter: grayscale(0); } .gallery:not([data-tags~='12227']) .cover img, .gallery:not([data-tags~='12227']) .cover .caption { transition: opacity 0.4s ease, filter 0.4s ease; } /* Settings Panel Enhancements */ .expand-icon { cursor: pointer; user-select: none; transition: all 0.3s ease; position: relative; } .expand-icon:hover { color: #e63946; } .expand-icon::after { content: '▼'; font-size: 12px; margin-left: 10px; transition: transform 0.3s ease; } .expand-icon.expanded::after { transform: rotate(180deg); } /* Mobile Responsiveness */ @media (max-width: 768px) { .mark-as-read-btn { width: 24px; height: 24px; font-size: 12px; } .tag-warning-badge { font-size: 8px; padding: 2px 6px; } .advanced-search-btn { padding: 6px 12px; font-size: 11px; } #favorite-tag-searches { padding: 10px; margin: 10px 0; } } @media (max-width: 480px) { .tag-warning-badge:nth-of-type(3) { display: none; /* Hide third badge on very small screens */ } .mark-as-read-btn { width: 20px; height: 20px; font-size: 10px; } } /* Animation Enhancements */ @keyframes markAsReadAnimation { 0% { transform: scale(1) rotate(0deg); } 50% { transform: scale(1.3) rotate(180deg); } 100% { transform: scale(1) rotate(360deg); } } .mark-as-read-animation { animation: markAsReadAnimation 0.6s ease; } @keyframes badgeAppear { 0% { opacity: 0; transform: scale(0.5) translateY(10px); } 100% { opacity: 1; transform: scale(1) translateY(0); } } .tag-warning-badge { animation: badgeAppear 0.4s ease; } @keyframes starFavorite { 0% { transform: scale(1); } 50% { transform: scale(1.5) rotate(72deg); } 100% { transform: scale(1) rotate(0deg); } } .tag-star-btn.favoriting { animation: starFavorite 0.5s ease; } /* Notification Enhancements */ .auto-mark-notification { position: fixed; top: 20px; right: 20px; background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%); color: white; padding: 12px 20px; border-radius: 8px; z-index: 10000; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.2); animation: slideInRight 0.4s ease; } @keyframes slideInRight { 0% { opacity: 0; transform: translateX(100px); } 100% { opacity: 1; transform: translateX(0); } } /* Hover Effects for Galleries */ .gallery:hover .tag-warning-badge { transform: scale(1.05); } .gallery:hover .mark-as-read-btn { border-color: rgba(255, 255, 255, 0.6); } .gallery:hover .read-badge { transform: scale(1.05); } /* Navigation link styling */ #read-manga-nav-link { border-radius: 3px; margin: 0 5px; } /* No image placeholder styling */ .no-image-placeholder { transition: all 0.3s ease; } .gallery:hover .no-image-placeholder { background: linear-gradient(135deg, #444 0%, #666 100%); } .no-image-placeholder i { transition: all 0.3s ease; } .gallery:hover .no-image-placeholder i { transform: scale(1.1); opacity: 0.7; } /* Dark Mode Compatibility */ @media (prefers-color-scheme: dark) { .advanced-search-btn { box-shadow: 0 2px 8px rgba(0,0,0,0.4); } #favorite-tag-searches { background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); border-color: rgba(102, 126, 234, 0.3); } } `; GM.addStyle(css); } // Override website's default gallery sizing for gallery-grid GM.addStyle(` @media screen and (min-width: 980px) { .gallery-grid .gallery, .gallery-grid .gallery-favorite, .gallery-grid .thumb-container { width: 19% !important; margin: 3px !important; } } /* Ensure gallery-grid uses flexbox layout for better control */ .gallery-grid { display: flex !important; flex-wrap: wrap !important; justify-content: flex-start !important; align-items: flex-start !important; gap: 0 !important; padding: 20px 10px !important; margin: 0 auto !important; max-width: 1200px !important; } @media (max-width: 979px) { .gallery-grid .gallery, .gallery-grid .gallery-favorite, .gallery-grid .thumb-container { width: 48% !important; margin: 1% !important; } } @media (max-width: 600px) { .gallery-grid .gallery, .gallery-grid .gallery-favorite, .gallery-grid .thumb-container { width: 100% !important; margin: 5px 0 !important; } } `); // Function to adjust the position of a specific element setInterval(function() { const pageNumberDisplay = $('.page-number-display'); if (pageNumberDisplay.length > 0) { $('.read-manga-gallery .remove-read-btn').css('top', '30px'); } }, 100); // Initialize enhanced CSS addEnhancedCSS(); //------------------------ **Enhanced CSS Styling and Visual Enhancements** ------------------ //------------------------ **Feature Integration and Coordination** ------------------ /** * Feature Integration System * Coordinates all new features to work together seamlessly */ class FeatureIntegrationSystem { constructor() { this.systems = {}; this.initialized = false; this.init(); } /** * Initialize the integration system */ async init() { if (this.initialized) return; try { // Initialize all systems in the correct order await this.initializeSystems(); // Set up cross-system communication this.setupCommunication(); // Set up unified event handling this.setupUnifiedEvents(); // Apply integrated styling this.applyIntegratedStyling(); this.initialized = true; console.log('Nhentai Plus+ Enhanced Features initialized successfully'); } catch (error) { console.error('Error initializing enhanced features:', error); } } /** * Initialize all systems in the correct order */ async initializeSystems() { // Initialize opacity system first (affects visual rendering) if (await GM.getValue('markAsReadEnabled', true) || await GM.getValue('tagWarningEnabled', true)) { this.systems.opacity = opacityFadeSystem; } // Initialize language detection (needed for other systems) this.systems.language = languageDetectionSystem; // Initialize tag warning system (depends on language detection) if (await GM.getValue('tagWarningEnabled', true)) { this.systems.tagWarning = tagWarningSystem; } // Initialize mark as read system (depends on opacity system) if (await GM.getValue('markAsReadEnabled', true)) { this.systems.markAsRead = markAsReadSystem; } // Initialize advanced search system (independent) this.systems.advancedSearch = advancedSearchSystem; // Initialize read manga page system (independent) this.systems.readMangaPage = readMangaPageSystem; } /** * Set up communication between systems */ setupCommunication() { // When opacity settings change, update all systems document.addEventListener('opacitySettingsChanged', (event) => { if (this.systems.markAsRead) { this.systems.markAsRead.settings.readOpacity = event.detail.readOpacity; this.systems.markAsRead.settings.nonEnglishOpacity = event.detail.nonEnglishOpacity; } }); // When favorite tags change, update search system document.addEventListener('favoriteTagsChanged', (event) => { if (this.systems.advancedSearch) { this.systems.advancedSearch.createFavoriteTagSearchButtons(); } }); // When read status changes, update visual systems document.addEventListener('readStatusChanged', (event) => { if (this.systems.opacity) { this.systems.opacity.applyOpacitySettings(); } }); } /** * Set up unified event handling */ setupUnifiedEvents() { // Handle page navigation let currentUrl = window.location.href; const observer = new MutationObserver(() => { if (window.location.href !== currentUrl) { currentUrl = window.location.href; this.handlePageChange(); } }); observer.observe(document.body, { childList: true, subtree: true }); // Handle dynamic content loading this.setupDynamicContentHandler(); } /** * Handle page changes */ async handlePageChange() { // Small delay to let the page load setTimeout(async () => { // Re-initialize systems that need to process new content if (this.systems.language) { this.systems.language.detectAndMarkLanguages(); } if (this.systems.tagWarning) { this.systems.tagWarning.processGalleries(); this.systems.tagWarning.processGalleryPage(); } if (this.systems.markAsRead) { this.systems.markAsRead.addMarkAsReadButtons(); this.systems.markAsRead.applyReadStatus(); this.systems.markAsRead.setupAutoMark(); } if (this.systems.advancedSearch) { this.systems.advancedSearch.addAdvancedSearchButtons(); this.systems.advancedSearch.createFavoriteTagSearchButtons(); } if (this.systems.opacity) { this.systems.opacity.applyOpacitySettings(); } }, 500); } /** * Set up handler for dynamically loaded content */ setupDynamicContentHandler() { const observer = new MutationObserver((mutations) => { let hasNewGalleries = false; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList && node.classList.contains('gallery')) { hasNewGalleries = true; } else if (node.querySelectorAll) { const galleries = node.querySelectorAll('.gallery'); if (galleries.length > 0) { hasNewGalleries = true; } } } }); }); if (hasNewGalleries) { this.processNewGalleries(); } }); observer.observe(document.body, { childList: true, subtree: true }); } /** * Process newly added galleries */ processNewGalleries() { // Process with all systems if (this.systems.language) { this.systems.language.detectAndMarkLanguages(); } if (this.systems.tagWarning) { this.systems.tagWarning.processGalleries(); } if (this.systems.markAsRead) { this.systems.markAsRead.addMarkAsReadButtons(); this.systems.markAsRead.applyReadStatus(); } if (this.systems.opacity) { this.systems.opacity.applyOpacitySettings(); } } /** * Apply integrated styling that coordinates all systems */ applyIntegratedStyling() { const css = ` /* Ensure proper z-index stacking */ .gallery .cover { position: relative; } .gallery .caption { z-index: 20; } .gallery .caption * { z-index: 21; } .gallery .mark-as-read-btn { z-index: 15; } .gallery .read-badge { z-index: 14; } .gallery .tag-warning-badge { z-index: 13; } .gallery .language-flag { z-index: 12; } /* Ensure badges don't overlap with buttons */ .gallery .tag-warning-badge { max-width: calc(100% - 40px); /* Leave space for mark-as-read button */ } /* Smooth transitions for all interactive elements */ .gallery * { transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; } /* Ensure readability of overlaid elements */ .gallery .mark-as-read-btn, .gallery .read-badge, .gallery .tag-warning-badge { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } `; GM.addStyle(css); } /** * Update settings for all systems */ async updateAllSettings() { const settings = { markAsReadEnabled: await GM.getValue('markAsReadEnabled', true), autoMarkReadEnabled: await GM.getValue('autoMarkReadEnabled', true), nonEnglishOpacity: await GM.getValue('nonEnglishOpacity', 0.2), readGalleriesOpacity: await GM.getValue('readGalleriesOpacity', 0.6), tagWarningEnabled: await GM.getValue('tagWarningEnabled', true), blacklistTagsList: await GM.getValue('blacklistTagsList', []), warningTagsList: await GM.getValue('warningTagsList', []), favoriteTagsList: await GM.getValue('favoriteTagsList', []) }; // Update each system with new settings Object.values(this.systems).forEach(system => { if (system && system.loadSettings) { system.loadSettings(); } }); // Trigger re-processing this.handlePageChange(); } /** * Get status of all systems */ getSystemStatus() { const status = {}; Object.entries(this.systems).forEach(([name, system]) => { status[name] = { initialized: !!system, enabled: system && system.settings ? system.settings.enabled : false }; }); return status; } } // Global integration system instance let featureIntegrationSystem; /** * Initialize all enhanced features */ async function initializeEnhancedFeatures() { // Wait for DOM to be ready if (document.readyState === 'loading') { await new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve); }); } // Initialize the integration system featureIntegrationSystem = new FeatureIntegrationSystem(); } // Initialize enhanced features initializeEnhancedFeatures(); // Expose global functions for debugging and external access window.nhentaiPlusEnhanced = { systems: { integration: () => featureIntegrationSystem, markAsRead: () => markAsReadSystem, opacity: () => opacityFadeSystem, language: () => languageDetectionSystem, tagWarning: () => tagWarningSystem, advancedSearch: () => advancedSearchSystem }, utils: { refreshAllSystems: () => featureIntegrationSystem?.handlePageChange(), updateSettings: () => featureIntegrationSystem?.updateAllSettings(), getStatus: () => featureIntegrationSystem?.getSystemStatus() } }; //------------------------ **Feature Integration and Coordination** ------------------ //------------------------ **Validation and Testing** ------------------ /** * Validation and Testing System * Ensures all features work correctly and provides debugging information */ class ValidationSystem { constructor() { this.testResults = {}; this.performanceMetrics = {}; } /** * Run comprehensive validation tests */ async runValidationTests() { console.log('🧪 Running Nhentai Plus+ Enhanced Features Validation...'); const tests = [ this.testSettingsIntegration, this.testMarkAsReadSystem, this.testOpacitySystem, this.testLanguageDetection, this.testTagWarningSystem, this.testAdvancedSearch, this.testBackwardCompatibility, this.testPerformance ]; for (const test of tests) { try { await test.call(this); } catch (error) { console.error(`❌ Test failed: ${test.name}`, error); this.testResults[test.name] = { status: 'failed', error: error.message }; } } this.reportResults(); } /** * Test settings integration */ async testSettingsIntegration() { const testName = 'Settings Integration'; console.log(`🔧 Testing ${testName}...`); // Test that all new settings exist const requiredSettings = [ 'markAsReadEnabled', 'autoMarkReadEnabled', 'nonEnglishOpacity', 'readGalleriesOpacity', 'tagWarningEnabled', 'blacklistTagsList', 'warningTagsList', 'favoriteTagsList' ]; for (const setting of requiredSettings) { const value = await GM.getValue(setting); if (value === undefined) { throw new Error(`Setting ${setting} not found`); } } // Test settings form elements exist const formElements = [ '#markAsReadEnabled', '#autoMarkReadEnabled', '#nonEnglishOpacity', '#readGalleriesOpacity', '#tagWarningEnabled', '#blacklistTags', '#warningTags', '#favoriteTags' ]; if (window.location.href.includes('/settings')) { for (const selector of formElements) { if (!document.querySelector(selector)) { console.warn(`⚠️ Settings form element ${selector} not found`); } } } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test Mark as Read system */ async testMarkAsReadSystem() { const testName = 'Mark as Read System'; console.log(`📖 Testing ${testName}...`); const enabled = await GM.getValue('markAsReadEnabled', true); if (!enabled) { this.testResults[testName] = { status: 'skipped', reason: 'Feature disabled' }; return; } // Test that the system exists if (!window.nhentaiPlusEnhanced?.systems?.markAsRead()) { throw new Error('Mark as Read system not initialized'); } // Test on gallery pages const galleries = document.querySelectorAll('.gallery'); if (galleries.length > 0) { const hasButtons = Array.from(galleries).some(gallery => gallery.querySelector('.mark-as-read-btn') ); if (!hasButtons) { console.warn('⚠️ No mark-as-read buttons found on galleries'); } } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test Opacity system */ async testOpacitySystem() { const testName = 'Opacity System'; console.log(`🎨 Testing ${testName}...`); // Test CSS custom properties are set const rootStyle = getComputedStyle(document.documentElement); const nonEnglishOpacity = rootStyle.getPropertyValue('--non-english-opacity'); const readOpacity = rootStyle.getPropertyValue('--read-galleries-opacity'); if (!nonEnglishOpacity || !readOpacity) { throw new Error('CSS custom properties not set'); } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test Language Detection */ async testLanguageDetection() { const testName = 'Language Detection'; console.log(`🌐 Testing ${testName}...`); if (!window.nhentaiPlusEnhanced?.systems?.language()) { throw new Error('Language detection system not initialized'); } // Test on galleries const galleries = document.querySelectorAll('.gallery'); if (galleries.length > 0) { const hasLanguageData = Array.from(galleries).some(gallery => gallery.getAttribute('data-detected-language') ); if (!hasLanguageData) { console.warn('⚠️ No language detection data found on galleries'); } } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test Tag Warning system */ async testTagWarningSystem() { const testName = 'Tag Warning System'; console.log(`⚠️ Testing ${testName}...`); const enabled = await GM.getValue('tagWarningEnabled', true); if (!enabled) { this.testResults[testName] = { status: 'skipped', reason: 'Feature disabled' }; return; } if (!window.nhentaiPlusEnhanced?.systems?.tagWarning()) { throw new Error('Tag warning system not initialized'); } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test Advanced Search system */ async testAdvancedSearch() { const testName = 'Advanced Search'; console.log(`🔍 Testing ${testName}...`); if (!window.nhentaiPlusEnhanced?.systems?.advancedSearch()) { throw new Error('Advanced search system not initialized'); } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test backward compatibility */ async testBackwardCompatibility() { const testName = 'Backward Compatibility'; console.log(`🔄 Testing ${testName}...`); // Test that existing features still work const existingFeatures = [ 'findSimilarEnabled', 'bookmarksEnabled', 'englishFilterEnabled', 'autoLoginEnabled' ]; for (const feature of existingFeatures) { const value = await GM.getValue(feature); if (value === undefined) { console.warn(`⚠️ Existing feature ${feature} setting not found`); } } this.testResults[testName] = { status: 'passed' }; console.log(`✅ ${testName} passed`); } /** * Test performance */ async testPerformance() { const testName = 'Performance'; console.log(`⚡ Testing ${testName}...`); const startTime = performance.now(); // Simulate feature operations if (window.nhentaiPlusEnhanced?.utils?.refreshAllSystems) { await window.nhentaiPlusEnhanced.utils.refreshAllSystems(); } const endTime = performance.now(); const duration = endTime - startTime; this.performanceMetrics.refreshTime = duration; if (duration > 1000) { // More than 1 second console.warn(`⚠️ Performance warning: Refresh took ${duration.toFixed(2)}ms`); } this.testResults[testName] = { status: 'passed', metrics: { refreshTime: duration } }; console.log(`✅ ${testName} passed (${duration.toFixed(2)}ms)`); } /** * Report test results */ reportResults() { console.log('\n📊 Validation Results:'); console.table(this.testResults); const passed = Object.values(this.testResults).filter(r => r.status === 'passed').length; const failed = Object.values(this.testResults).filter(r => r.status === 'failed').length; const skipped = Object.values(this.testResults).filter(r => r.status === 'skipped').length; console.log(`\n✅ Passed: ${passed} | ❌ Failed: ${failed} | ⏭️ Skipped: ${skipped}`); if (failed === 0) { console.log('🎉 All tests passed! Nhentai Plus+ Enhanced Features are working correctly.'); } else { console.log('⚠️ Some tests failed. Check the results above for details.'); } } } // Run validation in development mode or when requested if (window.location.search.includes('nhentai-plus-validate') || localStorage.getItem('nhentai-plus-debug') === 'true') { setTimeout(() => { const validator = new ValidationSystem(); validator.runValidationTests(); }, 2000); } // Add validation to global object if (window.nhentaiPlusEnhanced) { window.nhentaiPlusEnhanced.validation = { run: () => { const validator = new ValidationSystem(); return validator.runValidationTests(); } }; } //------------------------ **Validation and Testing** ------------------ //------------------------ **Mark as Read System** ------------------ // Function to add title attributes to gallery captions for better hover tooltips async function addGalleryCaptionTooltips() { const galleries = document.querySelectorAll('.gallery'); const galleryCaptionTooltipsEnabled = await GM.getValue('galleryCaptionTooltipsEnabled', true); const cachedData = await GM.getValue('readGalleriesCache', {}); galleries.forEach(gallery => { const caption = gallery.querySelector('.caption'); if (caption && !caption.hasAttribute('title')) { let titleText = caption.textContent.trim(); let tagsText = ''; const link = gallery.querySelector('a[href*="/g/"]'); const match = link && link.getAttribute('href').match(/\/g\/(\d+)\//); const id = match ? match[1] : null; if (id && cachedData && cachedData[id] && Array.isArray(cachedData[id].tags) && cachedData[id].tags.length) { tagsText = `\n\nTags: ${cachedData[id].tags.join(', ')}`; } caption.setAttribute('title', titleText + tagsText); if (galleryCaptionTooltipsEnabled) { caption.classList.add('tooltip-enabled'); } } }); } // Initialize gallery caption tooltips $(document).ready(function() { // Add tooltips to existing galleries addGalleryCaptionTooltips(); // Watch for new galleries being added dynamically const observer = new MutationObserver(async function(mutations) { const galleryCaptionTooltipsEnabled = await GM.getValue('galleryCaptionTooltipsEnabled', true); const cachedData = await GM.getValue('readGalleriesCache', {}); mutations.forEach(function(mutation) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.classList && node.classList.contains('gallery')) { const caption = node.querySelector('.caption'); if (caption && !caption.hasAttribute('title')) { let titleText = caption.textContent.trim(); let tagsText = ''; const link = node.querySelector('a[href*="/g/"]'); const match = link && link.getAttribute('href').match(/\/g\/(\d+)\//); const id = match ? match[1] : null; if (id && cachedData && cachedData[id] && Array.isArray(cachedData[id].tags) && cachedData[id].tags.length) { tagsText = `\n\nTags: ${cachedData[id].tags.join(', ')}`; } caption.setAttribute('title', titleText + tagsText); if (galleryCaptionTooltipsEnabled) { caption.classList.add('tooltip-enabled'); } } } const galleries = node.querySelectorAll && node.querySelectorAll('.gallery'); if (galleries) { galleries.forEach(gallery => { const caption = gallery.querySelector('.caption'); if (caption && !caption.hasAttribute('title')) { let titleText = caption.textContent.trim(); let tagsText = ''; const link = gallery.querySelector('a[href*="/g/"]'); const match = link && link.getAttribute('href').match(/\/g\/(\d+)\//); const id = match ? match[1] : null; if (id && cachedData && cachedData[id] && Array.isArray(cachedData[id].tags) && cachedData[id].tags.length) { tagsText = `\n\nTags: ${cachedData[id].tags.join(', ')}`; } caption.setAttribute('title', titleText + tagsText); if (galleryCaptionTooltipsEnabled) { caption.classList.add('tooltip-enabled'); } } }); } } }); } }); }); // Start observing observer.observe(document.body, { childList: true, subtree: true }); }); // Initialize AutoSync on page load $(document).ready(async function() { // Wait a bit for the page to fully load before initializing autosync setTimeout(async () => { try { await autoSyncManager.initialize(); console.log('AutoSync initialized on page load'); } catch (error) { console.error('Failed to initialize AutoSync on page load:', error); } }, 3000); // 3 second delay to ensure everything is loaded }); //----------------------------------------------------------**Manga-Sync**--------------------------------------------------------------------