Enhances the site with instant previews, visual markers for favorites, a reading queue, tag tools, smart navigation, and more.
// ==UserScript==
// @name NHentai Flow
// @namespace NEnhanced
// @version 1.6.2
// @description Enhances the site with instant previews, visual markers for favorites, a reading queue, tag tools, smart navigation, and more.
// @author Testador
// @match https://nhentai.net/*
// @grant GM_addStyle
// @grant GM_openInTab
// @run-at document-end
// @icon data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIj4KCTxkZWZzPgoJCTxpbWFnZSAgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGlkPSJpbWcxIiBocmVmPSJkYXRhOmltYWdlL3BuZztiYXNlNjQsaVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQWdBQUFBSUFBUU1BQUFET3RrYTVBQUFBQVhOU1IwSUIyY2tzZndBQUFBTlFURlJGSWpSRklTL3B2QUFBQUl4SlJFRlVlSnp0ekRFTkFBQU1BNkRWdittSjZOVUVCSkFyUlNBUUNBUUNnVUFnRUFnRUFvRkFJQkFJQkFLQlFDQVFDQVFDZ1VBZ0VBZ0VBb0ZBSUJBSUJBS0JRQ0FRQ0FRQ2dVQWdFQWdFQW9GQUlCQUlCQUtCUUNBUUNBUUNnVUFnRUFnRUFvRkFJQkFJQkFLQlFDQVFDQVFDZ1VBZ0VBZ0VBb0ZBSUJBSUJBS0JRQ0FRQ0FRQ2dXQTdlTXFlQWdFaVJEM0hBQUFBQUVsRlRrU3VRbUNDIi8+Cgk8L2RlZnM+Cgk8c3R5bGU+CgkJLnMwIHsgZmlsbDogI2VkMjc1NCB9IAoJCS5zMSB7IGZpbGw6ICNlY2Q5ZmYgfSAKCTwvc3R5bGU+Cgk8dXNlIGlkPSJCYWNrZ3JvdW5kIiBocmVmPSIjaW1nMSIgeD0iMCIgeT0iMCIvPgoJPHBhdGggY2xhc3M9InMwIiBkPSJtMTM2LjYgMzI0LjA3YzkuNjUtMi40MSAxOS4wNy0zLjM4IDI4LTMuMzggMzUuMjUgMCA2Ni42MyAxNy4xNCA5MC4yOSA0OS45NyAxNC0xMi4wNyAyNC4zOC0xNC43MiAzMi44My0xNC43MiA1Ljc5IDAgMTAuODYgMS4yIDE1LjQ1IDEuMiA5LjQxIDAgMTcuMzgtNC44MiAyOC4yNC0zNC43Ni02Ljc2LTMuMTQtMTQuNzMtMy42Mi0yMi45My0zLjYyLTMuMTQgMC02LjI4LTAuMTEtOS40MiAwLTQwLjg0IDEuNDUtMTExLjQtNzIuODYtMTIuNzktMTI4LjE4LTYuNTYtNi43Mi0xMDAuMzgtOTkuOTEtMjA3Ljc3LTcxLjA2LTI5LjgxIDguMDEtNTIuODggMjMuMzMtNjkuNSAzNy44OCA0NS41My0xNC45NCA5NS43MSAwLjkxIDEyNC4xMSAzOS4wNyAyNy43NiAzNy4yOSAyOS4xOSA4OC40OCAzLjQ5IDEyNy42eiIvPgoJPHBhdGggaWQ9Ii5GIiBjbGFzcz0iczEiIGQ9Im0xNTIuMTEgNTEyYzMzLjYgMCA2MS41My0yNCA2MS41My02MC42NSAwLTMwLjk5LTIxLjM5LTUxLjkzLTUzLjY4LTUxLjkzLTM0LjQ3IDAtNjEuOTYgMjQtNjEuOTYgNjAuNjUgMCAzMC4xMSAyMi4yNSA1MS45MyA1NC4xMSA1MS45M3ptMjg4Ljg3LTIzMy40NWMxNy40NiAwIDM4LjQgMy45MiA1My4yNCAxMy4wOWw0NC4wNy03NS45M2MtMjIuNjktMTMuMDktNjAuNjUtMjAuNTEtOTIuNTEtMjAuNTEtMTAxLjIzIDAtMTU4LjQgNDUuODItMTc1Ljg1IDEzMy41M2wtMzUuNzggMTc4LjkxaDEwMi45OGwxOS42My05Ny43NWgxMDcuNzlsMTUuNy03Ny42N2gtMTA4LjIxbDEuMzEtNS42N2M2LjU0LTMyLjczIDMwLjU0LTQ4IDY3LjYzLTQ4eiIvPgo8L3N2Zz4=
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ==========================================================================
// 0. SETTINGS & CONFIGURATION MANAGER
// ==========================================================================
const DEFAULT_SETTINGS = {
previewNav: true,
highlightVisited: true,
showTagOverlay: true,
enableQueue: true,
enableTagSelect: true,
enableSavedSearch: true,
smartNav: true,
paginationRight: false,
enableContextMenu: true,
smartNavScroll: 1500,
smartNavSwipe: 400
};
const NUM_LIMITS = { smartNavScroll: [300, 6000, 100], smartNavSwipe: [100, 900, 25] };
function numSetting(key) {
const lim = NUM_LIMITS[key];
const v = Number(settings[key]);
if (!lim || !Number.isFinite(v)) return DEFAULT_SETTINGS[key];
return Math.min(lim[1], Math.max(lim[0], v));
}
function readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (e) {
return fallback;
}
}
function readList(key) {
const v = readJSON(key, []);
return Array.isArray(v) ? v : [];
}
function loadSettings(stored) {
const out = { ...DEFAULT_SETTINGS };
if (stored && typeof stored === 'object') {
Object.keys(DEFAULT_SETTINGS).forEach(k => { if (k in stored) out[k] = stored[k]; });
}
return out;
}
let settings = loadSettings(readJSON('nhentai_flow_settings', {}));
function saveSettings() {
localStorage.setItem('nhentai_flow_settings', JSON.stringify(settings));
applySettings();
}
function applySettings() {
const body = document.body;
settings.highlightVisited ? body.classList.add('f-visited') : body.classList.remove('f-visited');
settings.paginationRight ? body.classList.add('f-pag-right') : body.classList.remove('f-pag-right');
settings.showTagOverlay ? body.classList.add('f-tags') : body.classList.remove('f-tags');
settings.enableQueue ? body.classList.add('f-queue') : body.classList.remove('f-queue');
settings.enableTagSelect ? body.classList.add('f-tag-select') : body.classList.remove('f-tag-select');
settings.enableSavedSearch ? body.classList.add('f-saved-search') : body.classList.remove('f-saved-search');
settings.previewNav ? body.classList.remove('disable-preview-nav') : body.classList.add('disable-preview-nav');
}
applySettings();
const SEL = {
// layout
content: '#content',
nav: 'nav',
navList: 'nav[role="navigation"] ul',
navListFallback: '.menu.left',
favoritesLink: 'nav a[href*="favorites"]',
searchForm: 'form.search',
searchInput: 'input[name="q"]',
// gallery cards (home, searches, favorites)
gallery: '.gallery',
cover: 'a.cover',
coverImage: 'a.cover img',
caption: '.caption',
galleryLink: 'a[href*="/g/"]',
// listings
sortBar: '.sort',
popularBlock: '.index-popular',
pagination: '.pagination',
paginationLast: '.pagination .last',
paginationPage: '.pagination .page',
paginationNext: '.pagination a.next, a[rel="next"]',
removeButton: '.remove-button',
// gallery page (/g/ID/)
infoButtons: '#info-block .buttons',
galleryTitle: 'h1.title',
bigCover: '#cover img',
tags: '#tags',
tagLink: 'a.tagchip, a.tag',
tagLinkSelected: '.tagchip.tag-selected, .tag.tag-selected',
tagName: '.name',
favoriteButton: '#favorite',
favoriteLabel: '.text',
commentList: '#comment-container',
commentPost: '#comment-post-container',
comment: '.comment',
// reader (/g/ID/PAGE/)
imageContainer: '#image-container',
readerBar: '.reader-bar',
readerButtonsRight: '.reader-buttons-right',
readerNext: '.reader-pagination .next',
readerPrev: '.reader-pagination .previous'
};
const SITE_CLASS = { grid: 'container index-container' };
// ==========================================================================
// 1. SHARED UTILS & CSS
// ==========================================================================
const stopEvent = (e) => { e.preventDefault(); e.stopPropagation(); };
const stopPropOnly = (e) => { e.stopPropagation(); };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const DOM_PARSER = new DOMParser();
const metaCache = new Map();
const states = new Map();
let hoveredGallery = null;
let hoverTimeout = null;
let readingQueue = readList('nhentai_queue_v1');
const pendingQueueDeletes = new Set();
let favCache = new Set(readList('nhentai_flow_favs'));
let saveQueueTimeout;
let currentSortMode = localStorage.getItem('nhentai_queue_sort') || 'newest';
let dockCurrentPage = 1;
const DOCK_ITEMS_PER_PAGE = 12;
let isReader = false;
function checkPage() {
const path = window.location.pathname;
const currentlyReader = /^\/g\/\d+\/\d+/.test(path);
if (currentlyReader !== isReader || !document.body.dataset.checked) {
isReader = currentlyReader;
document.body.dataset.checked = "true";
if (isReader) {
document.body.classList.remove('is-gallery-page');
} else {
document.body.classList.add('is-gallery-page');
}
initQueueWidget();
}
}
checkPage();
const css = `
/* --- CORE PREVIEW STYLES --- */
.gallery, .gallery-favorite { position: relative; vertical-align: bottom; }
.gallery:hover, .gallery.is-previewing { z-index: 150; }
.gallery.is-previewing { display: inline-flex !important; flex-direction: column; justify-content: flex-end; }
.gallery.is-previewing .cover { padding-bottom: 0 !important; height: auto !important; display: block; flex-grow: 0; }
.gallery.is-previewing .cover img { position: relative !important; height: auto !important; width: 100% !important; max-height: none !important; object-fit: contain; }
.inline-preview-ui { opacity: 0; pointer-events: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; }
.gallery:hover .inline-preview-ui { opacity: 1; pointer-events: auto; }
.card-overlay { right: 50px !important; z-index: 14 !important; }
@media (min-width: 600px) { span.username { display: none } }
/* --- HIGHLIGHT VISITED --- */
body.f-visited .gallery a:visited .caption { background: #3d643f !important; }
/* --- MARKERS SYSTEM --- */
.reading-marker { position: absolute; top: -2px; font-size: large; z-index: 5; pointer-events: none; filter: drop-shadow(0 0 1px #000); }
.marker-queue { left: 45px; }
.marker-fav { left: 10px; }
.marker-progress { right: 10px; }
.marker-yellow i { color: #ed7c26; }
.marker-blue i { color: #5b26ed; }
.marker-pink i { color: #ed2553; }
.gallery:hover .reading-marker { opacity: 0; }
/* --- CONTEXT MENU --- */
.nh-context-menu { position: fixed; z-index: 10000; max-width: calc(100vw - 16px); width: 160px; background: #1f1f1f; border-radius: 5px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); display: none; flex-direction: column; padding: 5px 0 0 0 ; font-size: 12px; }
.nh-context-menu.is-visible { display: flex; }
.nh-cm-item { padding: 8px 15px; cursor: pointer; display: flex; color: #ddd; }
.nh-cm-item:hover { background: #4d4d4d; }
.nh-cm-footer { margin-top: 5px; padding: 8px 0; border-top: 1px solid #333; color: #666; font-size: 0.7em; cursor: default; background: #1a1a1a; border-radius: 0 0 5px 5px; }
.nh-cm-separator { height: 1px; background: #333; margin: 5px 5px}
.nh-cm-parent { justify-content: space-between; align-items: center; }
.nh-cm-group button.nh-cm-item { width: 100%; box-sizing: border-box; border: 0; background: transparent; font: inherit; }
.nh-cm-group button.nh-cm-item:hover:not(:disabled),
.nh-cm-group button.nh-cm-item:focus-visible,
.nh-cm-group.is-open > .nh-cm-parent { background: #4d4d4d; }
.nh-cm-submenu { position: fixed; z-index: 10001; display: none; flex-direction: column; padding: 5px 0; background: #1f1f1f; border-radius: 5px; box-shadow: 0 2px 6px rgba(0,0,0,.5); }
.nh-cm-group.is-open > .nh-cm-submenu { display: flex; }
.nh-cm-group button.nh-cm-item:disabled { opacity: .45; cursor: default; }
.nh-cm-explore-submenu { width: 130px; max-height: calc(33vh - 16px); overflow-y: auto; overscroll-behavior: contain; box-sizing: border-box; }
.nh-cm-section { padding: 6px 15px 3px; color: #fff; font-size: 11px; font-weight: bold; text-transform: uppercase; text-align: left; }
.nh-cm-status { padding: 8px 15px; color: #999; }
.nh-cm-explore-link { white-space: normal; overflow-wrap: anywhere; text-align: left; }
/* --- UI ELEMENTS (Hotzones/Seek) --- */
.hotzone { position: absolute; top: 0; height: calc(100% - 15px); width: 40%; cursor: default; z-index: 20; }
.hotzone-left { left: 0; } .hotzone-right { right: 0; }
.seek-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 30px; z-index: 40; cursor: pointer; display: flex; align-items: flex-end; }
.seek-bg { width: 100%; height: 15px; background: rgba(31,31,31,0.5); transform: scaleY(0.2); transform-origin: bottom; transition: transform 0.1s; position: relative; }
.seek-container:hover .seek-bg { transform: scaleY(1); }
.seek-fill { height: 100%; background: #ed2553; width: 100%; transform: scaleX(0); transform-origin: left; transition: transform 0.1s; }
.seek-tooltip { position: absolute; bottom: 17px; transform: translateX(-50%); background: #ed2553; color: #fff; font-size: 10px; padding: 2px 4px; border-radius: .3em; opacity: 0; pointer-events: none; white-space: nowrap; font-weight: bold; transition: opacity 0.1s; }
.seek-container:hover .seek-tooltip { opacity: 1; }
/* --- PREVIEW NAV SETTINGS --- */
body.disable-preview-nav .hotzone,
body.disable-preview-nav .seek-container { display: none !important; }
/* --- TAGS & QUEUE TRIGGERS --- */
.tag-trigger, .queue-trigger { width: 18%; position: absolute; background: rgba(31,31,31,0.5); color: #fff; font-size: 10px; font-weight: 700; padding: 4px 0px; }
.tag-trigger { border-radius: .3em 0 .3em 0; z-index: 50; cursor: default; }
.tag-trigger::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.tag-trigger:hover::after { width: 275%; background: #404040; border-radius: .3em 0 .3em 0; z-index:-1; }
.queue-trigger { right: 0; border-radius: 0 .3em 0 .3em; z-index: 50; cursor: pointer; }
.queue-trigger:hover, .tag-trigger:hover { background: #404040; }
.queue-trigger.in-queue { background: #5b26ed; }
body:not(.f-tags) .tag-trigger { display: none !important; }
body:not(.f-queue) .queue-trigger { display: none !important; }
/* --- TAG POPUP --- */
.tag-popup { opacity: 0; pointer-events: none; transition: opacity 0.2s ease; position: absolute; top: 22px; left: 0px; width: 100%; max-height: 80%; overflow-y: auto; overscroll-behavior: contain; background: #1f1f1f; border-radius: 0 0 .3em .3em; padding: 2px 2px 10px; z-index: 60; cursor: default; font-weight: bold; text-align: left; }
.tag-trigger:hover + .tag-popup, .tag-popup:hover { opacity: 1; pointer-events: auto; }
.tag-category { margin-bottom: 2px; margin-top: 6px; font-size: 10px; text-transform: uppercase; }
.tag-pill { display: inline-block; background: #4d4d4d; padding: .13em .39em; margin: 1px; border-radius: .3em; font-size: 12px; }
.tag-pill.tier-mythic { border: 1px solid #b655f7; color: #d6a0fb; text-shadow: 0 0 5px rgba(168, 85, 247, 0.8); }
.tag-pill.tier-rare { border: 1px solid #eab308; color: #fef08a; }
.tag-pill.tier-uncommon { border: 1px solid #0740EB; }
.tag-pill.style-lgbt { border: none !important; background-image: linear-gradient(144deg, rgba(231, 0, 0, 1) 0%, rgba(255, 140, 0, 1) 20%, rgba(255, 239, 0, 1) 40%, rgba(0, 129, 31, 1) 60%, rgba(0, 68, 255, 1) 80%, rgba(118, 0, 137, 1) 100%); color: #000000 !important; font-weight: 900; text-shadow: 0 0 2px rgba(255,255,255,0.8); }
@media (max-width: 600px) {
.tag-pill { font-size: 11px; }
.tag-trigger::after { display: none; }
.tag-trigger, .queue-trigger { width: 21%; padding: 6px 0px; }}
/* --- READER STYLES --- */
#image-container { cursor: default; }
.exit-fs-indicator { display: none; }
:fullscreen .exit-fs-indicator { display: block; position: fixed; top: 0; left: 50%; transform: translateX(-50%); font-size: 40px; cursor: pointer; transition: transform 0.2s, opacity 0.2s, color 0.2s; padding: 20px 65px; opacity: 0; }
:fullscreen .exit-fs-indicator:hover { color: #ed2553; transform: translateX(-50%) scale(1.4); opacity: 1; }
.reader-bar:last-of-type .reader-buttons-right .zoom-buttons, .reader-bar:last-of-type .reader-buttons-right .reader-settings { display: none !important; }
@media (max-width: 600px) {
.reader-bar:last-of-type { flex-wrap: wrap !important; height: auto !important; padding-bottom: 8px !important; gap: 5px; }
.reader-bar:last-of-type .reader-buttons-right { width: 100% !important; justify-content: center !important; padding-top: 8px; }
.reader-bar:last-of-type .reader-buttons-right .btn { background: #4d4d4d; border-radius: 5px; }
}
/* --- SMART NAVIGATION & PAGINATION --- */
body.is-gallery-page #content { padding-bottom: 200px !important; }
@media (min-width: 1300px) {
body.is-gallery-page .pagination.desktop-pagination { position: fixed !important; top: 50% !important; align-items: center !important; transform: translateY(-50%) !important; display: flex !important; flex-direction: column !important; z-index: 4; left: 8px !important; right: auto !important; }
body.is-gallery-page a.first, body.is-gallery-page a.previous, body.is-gallery-page a.last, body.is-gallery-page a.next { transform: rotate(90deg); }
body.f-pag-right.is-gallery-page .pagination { left: auto !important; right: 16px !important; }
}
/* --- SETTINGS MODAL --- */
.nf-modal-overlay { --nf-accent: #ed2553; --nf-accent-hover: #ff3b6a; --nf-bg: #1b1b1b; --nf-bg-2: #232323; --nf-line: rgba(255,255,255,.08); --nf-text: #eaeaea; --nf-muted: #8c8c8c;
position: fixed; inset: 0; z-index: 999999; display: flex; justify-content: center; align-items: center; padding: 16px; box-sizing: border-box;
background: rgba(0,0,0,.72); opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .15s ease, visibility 0s linear .15s; }
.nf-modal-overlay.active { opacity: 1; visibility: visible; pointer-events: auto; transition: opacity .15s ease; }
.nf-modal { display: flex; flex-direction: column; width: 100%; max-width: 530px; max-height: min(90vh, 760px); box-sizing: border-box; overflow: hidden;
background: var(--nf-bg); color: var(--nf-text); border-radius: 5px; box-shadow: 0 24px 64px rgba(0,0,0,.6);
font-size: 14px; line-height: 1.4; transform: translateY(8px) scale(.98); transition: transform .15s ease; outline: none; }
.nf-modal-overlay.active .nf-modal { transform: none; }
.nf-modal *, .nf-modal *::before, .nf-modal *::after { box-sizing: border-box; }
.nf-modal-header { flex: none; display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: var(--nf-bg-2); }
.nf-modal-header h2 { margin: 0; font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; color: var(--nf-text); }
.nf-modal-header h2 i { color: var(--nf-accent); }
.nf-modal-close { flex: none; width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--nf-muted); font-size: 16px; cursor: pointer; transition: background .12s, color .12s; }
.nf-modal-close:hover { background: rgba(255,255,255,.08); color: #fff; }
.nf-modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 12px 16px; }
.nf-section-title { margin: 18px 8px 6px; font-size: 11px; text-align: left; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--nf-muted); }
.nf-setting-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 10px 8px; border-radius: 3px; cursor: pointer; transition: background .12s; }
.nf-setting-row:hover { background: rgba(255,255,255,.04); }
.nf-setting-text { display: flex; flex-direction: column; min-width: 0; }
.nf-setting-name { font-size: 14px; font-weight: 600; color: var(--nf-text); }
.nf-setting-desc { font-size: 12px; text-align: left; font-weight: normal; white-space: initial; color: var(--nf-muted); }
.nf-setting-checkbox { -webkit-appearance: none; appearance: none; flex: none; position: relative; width: 38px; height: 22px; margin: 0; border: 0; border-radius: 11px; background: #3b3b3b; cursor: pointer; transition: background .15s; }
.nf-setting-checkbox::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
.nf-setting-checkbox:checked { background: var(--nf-accent); }
.nf-setting-checkbox:checked::after { transform: translateX(16px); }
.nf-setting-checkbox:focus-visible, .nf-btn:focus-visible, .nf-modal-close:focus-visible { outline: 2px solid var(--nf-accent); outline-offset: 2px; }
.nf-range-row { margin: 2px 0 4px 18px; padding: 8px 8px 10px 14px; border-left: 2px solid var(--nf-line); border-radius: 0 8px 8px 0; transition: opacity .15s; }
.nf-range-row.is-disabled { opacity: .4; pointer-events: none; }
.nf-range-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.nf-range-right { display: flex; align-items: center; gap: 6px; }
.nf-range-value { font-size: 12px; font-weight: 700; color: var(--nf-accent); font-variant-numeric: tabular-nums; }
.nf-range-reset { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--nf-muted); font-size: 12px; cursor: pointer; transition: background .12s, color .12s; }
.nf-range-reset:hover { background: rgba(255,255,255,.08); color: #fff; }
.nf-setting-range { display: block; width: 100%; margin: 8px 0 0; accent-color: var(--nf-accent); cursor: pointer; }
.nf-setting-range:focus-visible, .nf-range-reset:focus-visible { outline: 2px solid var(--nf-accent); outline-offset: 2px; }
.nf-data-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 0 8px; }
.nf-btn { -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: none; border-radius: 3px; background: #2a2a2a; color: var(--nf-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s; }
.nf-btn:hover:not(:disabled) { background: #343434; border-color: rgba(255,255,255,.2); }
.nf-btn:disabled { opacity: .6; cursor: default; }
.nf-btn-primary { background: var(--nf-accent); border-color: var(--nf-accent); color: #fff; }
.nf-btn-primary:hover:not(:disabled) { background: var(--nf-accent-hover); border-color: var(--nf-accent-hover); }
.nf-modal-footer { flex: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 20px; background: var(--nf-bg-2); }
.nf-footer-hint { font-size: 12px; color: var(--nf-muted); }
.nf-footer-actions { display: flex; gap: 8px; }
@media (max-width: 520px) {
.nf-modal-overlay { padding: 10px; }
.nf-data-grid { grid-template-columns: 1fr; }
.nf-footer-hint { display: none; }
.nf-modal-footer { justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.nf-modal-overlay, .nf-modal, .nf-setting-checkbox, .nf-setting-checkbox::after { transition: none; }
}
/* --- GALLERY PAGE BTN --- */
@media (min-width: 1300px) { .btn-group { width: 580px; } }
.btn-queue-add { background: #5b26ed !important; }
.btn-queue-add:hover { background: #7f55f1 !important; }
.tag-container .tagchip.tag-selected .name { background: #3d643f !important; opacity: 1 !important; }
.tags-selecting-mode .tagchip:not(.tag-selected) { opacity: .5; }
body:not(.f-queue) .btn-queue-add, body:not(.f-queue) .btn-next-queue { display: none !important; }
body:not(.f-tag-select) .btn-tag-selector { display: none !important; }
body.tag-select-active #info > :not(#tags):not(.buttons), body.tag-select-active #cover { display: none !important; }
body.tag-select-active #info-block, body.tag-select-active #info {width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; }
.comments-scroll-btn { color: #d9d9d9; position: fixed; bottom: 20px; left: 20px; display: flex; padding: .7em; border-radius: 5px; min-width: 40px; min-height: 40px; background: #1a1a1a; cursor: pointer; align-items: center; justify-content: center; pointer-events: auto;}
.comments-count, .queue-count { position: absolute; top: -4px; right: -4px; background: #ed2553; font-size: 10px; font-weight: bold; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 4px; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
/* --- QUEUE --- */
.queue-dock { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; align-items: flex-end; z-index: 160; pointer-events: none; }
body:not(.f-queue) .queue-dock { display: none !important; }
.queue-toggle-btn { color: #d9d9d9; padding: .7em; border-radius: 5px; min-width: 40px; min-height: 40px; background: #1a1a1a; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; position: relative; pointer-events: auto; }
.queue-toggle-btn:hover { background: #333; }
.queue-toggle-btn.active { background: #2e2e2e; }
.queue-count { background: #5b26ed; }
.queue-panel { color: #d9d9d9; background: #1f1f1f; max-width: 95vw; width: 350px; border-radius: 5px; overflow: hidden; margin-bottom: 10px; box-shadow: 0 0 5px rgba(0,0,0,.5); display: block; visibility: hidden; opacity: 0; transform: translateX(100%); transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s; pointer-events: auto; }
.queue-panel.is-visible { visibility: visible; opacity: 1; transform: translateX(0); }
.queue-header { justify-content: end !important; }
.queue-header, .queue-dock-pagination { padding: 10px; background: #383838; display: flex; user-select: none; justify-content: space-between; align-items: center; font-weight: bold; }
.queue-header-actions .is-hidden { display: none !important; }
.queue-btn-action, .queue-dock-btn { cursor: pointer; user-select: none; font-size: 12px; color: #fff !important; background: #4d4d4d; padding: 4px 10px; border-radius: 3px; }
.queue-dock-btn { border: none; padding: 4px 18px; }
.queue-btn-action:hover, .queue-dock-btn:hover:not(:disabled) { background: #595959; }
.queue-done { background: #ed2553; }
.queue-done:hover { background: #f15478; }
.queue-dock-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.queue-list { display: grid; grid-template-columns: repeat(4, 1fr); scrollbar-width: none; touch-action: pan-y; touch-action: pan-x; gap: 6px; padding: 8px; max-height: 430px; overflow-y: auto; overscroll-behavior: contain; margin: 0; list-style: none; content-visibility: auto; contain-intrinsic-size: 430px; }
.queue-item { border-radius: .3em; background: #404040; animation: queue-fade-in 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
.queue-item:hover { background: #3d643f; }
.queue-image-wrapper { position: relative; display: block; border-radius: .3em .3em 0 0; overflow: hidden; }
.queue-item img { width: 100%; aspect-ratio: 100/141; object-fit: cover; display: block; content-visibility: auto; contain-intrinsic-size: 111.38px; }
.queue-id { color: #d9d9d9; font-size: 11px; padding: 3px; }
.queue-remove { position: absolute; top: 0; right: 0; background: rgba(31, 31, 31, .5); width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10; font-size: 24px; }
.queue-list:not(.is-editing) .queue-remove { display: none; }
.queue-list.is-editing .queue-item { background: #404040 !important; }
.queue-list.is-editing .queue-item:hover { background: rgba(89, 38, 38, .9) !important; }
.queue-list.is-editing .queue-item img { opacity: 0.6; filter: grayscale(40%); }
.queue-list.is-editing .cover-link { pointer-events: none; }
.queue-list.is-editing .queue-item.to-delete, .queue-list.is-editing .queue-item.to-delete:hover { background: #592626 !important; }
.queue-list.is-editing .queue-item.to-delete .queue-remove { background: rgba(37, 24, 24, .65); }
.queue-list.is-editing .queue-item.to-delete .queue-id { text-decoration: line-through; }
.queue-item:not(.to-delete) .queue-remove .fa-undo, .queue-item.to-delete .queue-remove .fa-times { display: none; }
@keyframes queue-fade-in {
0% { transform: translateY(20px); }
100% { transform: translateY(0); }
}
@media (max-width: 1200px) {
.btn-q-tool, .q-discover, .q-sort-label { font-size: 16px; }
.q-discover { min-width: 121px !important }
.queue-dock { bottom: 0; right: 0; }
.queue-toggle-btn { font-size: 20px;; border-radius: 5px 0 0 0; min-width: 51px; }
.comments-scroll-btn { bottom: 0; left: 0; font-size: 20px;; border-radius: 0 5px 0 0; min-width: 51px; }
.queue-count { right: 34px; }
.queue-toggle-btn:hover, .queue-toggle-btn.active { background: #1a1a1a; }
.queue-panel { margin-right: 10px; }
.queue-list.is-editing .queue-item:hover { background: #404040 !important; }
@keyframes queue-fade-in {
0% { transform: translateX(10px); }
100% { transform: translateX(0); }
}
}
/* --- QUEUE PAGE --- */
.queue-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; margin-top: -15px; margin-bottom: -4px; }
.queue-group { display: flex; margin: 10px; flex-wrap: wrap;}
.queue-group.segmented { border-radius: 5px; overflow: hidden; }
.queue-group.segmented .btn-q-tool { border-radius: 0; }
.queue-group.segmented .btn-q-tool + .btn-q-tool { border-left: 1px solid #0d0d0d; }
.queue-group.segmented .q-sort-label + .btn-q-tool { border-left: 1px solid #0d0d0d; }
.btn-q-tool, .q-sort-label, .q-discover { background: #1a1a1a; color: #d9d9d9; border: none; border-radius: 5px; padding: .5em; font-size: 20px; line-height: 1.42857143; cursor: pointer; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; }
.btn-q-tool::after { content: attr(data-text); font-weight: bold; height: 0; visibility: hidden; overflow: hidden; user-select: none; pointer-events: none; }
.btn-q-tool.active { background: #2e2e2e; font-weight: bold; }
.q-sort-label { border-radius: 0; cursor: default; }
.q-discover {background: linear-gradient(45deg, #5b26ed, #ed2553); min-width: 151px; }
.btn-q-tool:hover { background: #333; }
/* --- SAVED SEARCHES STYLES --- */
.search-saved-trigger { position: absolute; left: -5px; top: 0; height: 97%; width: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #888; z-index: 5; }
.search-saved-trigger:hover, .search-saved-trigger.is-active { color: #999; }
.saved-search-extension { position: relative; width: 100%; background: #1f1f1f; box-sizing: border-box; z-index: 5; display: block; overflow: hidden; max-height: 0; contain: layout paint; }
.saved-search-extension.is-visible { max-height: 500px; }
.sse-inner { padding: 0 20px; opacity: 0; transform: translateY(-5%); transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.saved-search-extension.is-visible .sse-inner { opacity: 1; transform: translateY(0); }
.sse-header { display: flex; align-items: center; margin-bottom: 12px; border-bottom: 1px solid #2e2e2e; padding: 8px 0; flex-wrap: wrap; }
.sse-actions { margin-left: auto; }
.btn-sse-save, .btn-sse-edit { background: #4d4d4d; color: #fff; border: none; padding: 0 12px; border-radius: 3px; line-height: 40px; cursor: pointer; font-weight: bold; }
.btn-sse-save:hover, .btn-sse-edit:hover { background: #595959; }
#btn-confirm-del, #btn-finish-reorder { background: #ed2553; }
#btn-confirm-del:hover, #btn-finish-reorder:hover { background: #f15478; }
.sse-empty { color: #999; font-style: italic; }
.sse-list { display: flex; flex-wrap: wrap; gap: 8px; padding-bottom: 20px; padding-right: 5px; overflow-y: auto; overscroll-behavior: contain; max-height: 40vh; }
.ss-pill { font-weight: 700; display: inline-flex; align-items: stretch; border-radius: .3em; overflow: hidden; user-select: none; border-left: 10px solid var(--ss-category-color, #888); }
.ss-pill[data-category="tag"] { --ss-category-color: hsl(0, 0%, 22%); }
.ss-pill[data-category="artist"] { --ss-category-color: #592673; }
.ss-pill[data-category="parody"] { --ss-category-color: #20604a; }
.ss-pill[data-category="character"] { --ss-category-color: #265973; }
.ss-pill[data-category="group"] { --ss-category-color: #735926; }
.ss-pill[data-category="language"] { --ss-category-color: #364563; }
.ss-pill[data-category="category"] { --ss-category-color: #862d2d; }
.ss-part { padding: .13em .39em; cursor: pointer; display: flex; align-items: center; background: #4d4d4d }
.ss-add { padding: .13em .69em; justify-content: center; background: #333; color: grey; box-shadow: inset 0 0 .4em #2b2b2b; min-width: 30px; }
.ss-add:hover { background: #404040; color: #fff; }
.ss-text:hover { background: #595959; color: #fff; }
.ss-add.ss-add-positive { background: #6c6; color: #fff; }
.ss-add.ss-add-positive:hover { background: #8cd98c; }
.ss-add.ss-add-negative { background: #643d3d; color: #fff;}
.ss-add.ss-add-negative:hover { background: #855151;}
.sse-list.delete-mode .ss-add, .sse-list.reorder-mode .ss-add { display: none }
.ss-pill.to-delete .ss-text, .ss-separator.to-delete { background: #251818; opacity: .7; text-decoration: line-through; }
.sse-list.reorder-mode .ss-pill { cursor: grab; }
.sse-list.reorder-mode .ss-pill:active, .sse-list.reorder-mode .ss-separator:active { cursor: grabbing; }
.sse-list.reorder-mode .ss-text { pointer-events: none; }
.ss-pill.is-dragging, .ss-separator.is-dragging { opacity: 0.4; transform: scale(0.90); transition: transform 0.2s; }
.ss-pill.is-pressing, .ss-separator.is-pressing { transform: scale(0.95); transition: transform 0.2s; opacity: 0.8; }
.ss-pill.drag-over { box-shadow: 5px 5px #ed2553; }
.ss-separator { flex-basis: 100%; height: 2px; background: #2e2e2e; margin: 4px 0; position: relative; }
.sse-list.reorder-mode .ss-separator { cursor: grab; height: 12px; background: transparent; border: 1px dashed #555; }
.ss-separator.drag-over { border: 2px solid #ed2553 !important; }
.sse-list.delete-mode .ss-separator { cursor: pointer; height: 8px; }
body:not(.f-saved-search) .search-saved-trigger,
body:not(.f-saved-search) .saved-search-extension { display: none !important; }
body:has(.saved-search-extension.is-visible) .ac-dropdown { display: none !important; }
.search input { padding-left: 2em; }
@media (max-width: 600px) { .sse-list { max-height: 410px; } }
/* --- QUEUE VIEW (own container, Svelte keeps owning #content) --- */
body.nf-queue-view #content { display: none !important; }
#nf-queue-root { padding-bottom: 200px !important; }
/* --- QUEUE PAGINATION FALLBACK (used only until the site's own pagination is captured) --- */
.nf-pag-fallback { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 4px; margin: 20px 0; }
.nf-pag-fallback a { display: inline-block; min-width: 34px; padding: 8px 10px; text-align: center; border-radius: 3px; background: #1f1f1f; color: #d9d9d9; text-decoration: none; font-weight: bold; }
.nf-pag-fallback a:hover { background: #383838; }
.nf-pag-fallback a.current { background: #ed2553; color: #fff; }
/* --- SCROLL PROGRESS BAR (smart navigation) --- */
#nh-scroll-progress-wrap { position: fixed; left: 0; bottom: 0; width: 100%; height: 4px; background: transparent; z-index: 2147483647; pointer-events: none; opacity: 0; transition: opacity 0.2s ease; }
#nh-scroll-progress-wrap.active { opacity: 1; }
#nh-scroll-progress-bar { height: 100%; width: 0%; background: #ed2553; box-shadow: 0 0 8px #ed2553; border-radius: 0 2px 2px 0; }
@media (pointer: coarse) { #nh-scroll-progress-wrap { height: 6px; bottom: env(safe-area-inset-bottom, 0px); } }
/* --- SEARCH SHORTCUT HINT --- */
.search-slash-hint { position: absolute; left: 30px; top: 50%; transform: translateY(-50%); color: #999; font-size: 12px; pointer-events: none; font-family: Consolas, monospace; }
form.search input:focus ~ .search-slash-hint, form.search input:not(:placeholder-shown) ~ .search-slash-hint { opacity: 0; }
form.search { position: relative; }
@media (max-width: 1000px) { .search-slash-hint { opacity: 0 } }
`;
(typeof GM_addStyle !== "undefined") ? GM_addStyle(css) : document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
// ==========================================================================
// 2. QUEUE LOGIC
// ==========================================================================
function saveQueue() {
updateQueueWidget();
updateAllQueueButtons();
clearTimeout(saveQueueTimeout);
saveQueueTimeout = setTimeout(() => {
localStorage.setItem('nhentai_queue_v1', JSON.stringify(readingQueue));
}, 500);
}
function toggleQueueItem(id, title, coverUrl, galleryUrl) {
const index = readingQueue.findIndex(i => i.id == id);
if (index > -1) {
readingQueue.splice(index, 1);
} else {
const newItem = { id, title, coverUrl, galleryUrl, addedAt: Date.now() };
readingQueue.push(newItem);
if (currentSortMode === 'newest') {
readingQueue.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
} else if (currentSortMode === 'oldest') {
readingQueue.sort((a, b) => (a.addedAt || 0) - (b.addedAt || 0));
}
}
saveQueue();
}
function isQueued(id) {
return readingQueue.some(i => i.id == id);
}
function clearQueue() {
if(confirm('Clear reading queue?')) {
readingQueue = [];
saveQueue();
if (window.location.search.includes('view=queue')) renderQueuePage();
}
}
function updateAllQueueButtons() {
if (!settings.enableQueue) return;
document.querySelectorAll(`${SEL.gallery}[data-gid]`).forEach(gallery => {
const id = gallery.dataset.gid;
const btn = gallery.querySelector('.queue-trigger');
if (btn) {
if (isQueued(id)) {
btn.classList.add('in-queue');
btn.innerHTML = '<i class="fa fa-check"></i>';
} else {
btn.classList.remove('in-queue');
btn.innerHTML = '<i class="fa fa-plus"></i>';
}
}
if (gallery.updateMarkersFn) {
gallery.updateMarkersFn();
}
});
const pageBtn = document.querySelector('.btn-queue-add');
if (pageBtn) {
const id = window.location.href.match(/\/g\/(\d+)/)?.[1];
if (id && isQueued(id)) {
pageBtn.innerHTML = '<i class="far fa-clock"></i> Remove';
pageBtn.classList.add('in-queue');
} else {
pageBtn.innerHTML = '<i class="fa fa-clock"></i> Queue';
pageBtn.classList.remove('in-queue');
}
}
}
function updateQueueWidget() {
if (!settings.enableQueue) return;
const list = document.querySelector('.queue-list');
const count = document.querySelector('.queue-count');
const paginationContainer = document.querySelector('.queue-dock-pagination');
if (!list || !count) return;
const queuedIds = new Set(readingQueue.map(item => String(item.id)));
for (const id of pendingQueueDeletes) {
if (!queuedIds.has(id)) pendingQueueDeletes.delete(id);
}
const doneBtn = document.querySelector('.queue-done');
if (doneBtn) {
doneBtn.classList.toggle(
'is-hidden',
!list.classList.contains('is-editing') || pendingQueueDeletes.size === 0
);
}
const totalItems = readingQueue.length;
count.textContent = totalItems;
count.style.display = totalItems > 0 ? 'flex' : 'none';
if (totalItems === 0) {
list.innerHTML = '<li style="grid-column: 1 / -1; padding: 20px; color: #999; font-size: 12px; font-style: italic;">Queue is empty.<p>Looking for inspiration?</p></li>';
if (paginationContainer) paginationContainer.style.display = 'none';
return;
}
const totalPages = Math.ceil(totalItems / DOCK_ITEMS_PER_PAGE);
if (dockCurrentPage > totalPages) dockCurrentPage = Math.max(1, totalPages);
const startIdx = (dockCurrentPage - 1) * DOCK_ITEMS_PER_PAGE;
const currentItems = readingQueue.slice(startIdx, startIdx + DOCK_ITEMS_PER_PAGE);
list.innerHTML = currentItems.map(item => `
<li class="queue-item${pendingQueueDeletes.has(String(item.id)) ? ' to-delete' : ''}" data-id="${item.id}">
<div class="queue-image-wrapper">
<div class="queue-remove" data-id="${item.id}">
<i class="fa fa-times"></i>
<i class="fa fa-undo" style="color: #facc15;"></i>
</div>
<a href="${item.galleryUrl}" class="cover-link">
<img src="${item.coverUrl}" loading="lazy">
</a>
</div>
<a title="${item.title}">
<div class="queue-id">#${item.id}</div>
</a>
</li>
`).join('');
if (paginationContainer) {
if (totalPages > 1) {
paginationContainer.style.display = 'flex';
paginationContainer.innerHTML = `
<button class="queue-dock-btn queue-dock-prev" ${dockCurrentPage === 1 ? 'disabled' : ''}><i class="fa fa-chevron-left"></i></button>
<span class="queue-page-info">${dockCurrentPage} / ${totalPages}</span>
<button class="queue-dock-btn queue-dock-next" ${dockCurrentPage === totalPages ? 'disabled' : ''}><i class="fa fa-chevron-right"></i></button>
`;
} else {
paginationContainer.style.display = 'none';
}
}
}
function initQueueWidget() {
if (!settings.enableQueue) return;
const queueDock = document.querySelector('.queue-dock');
if (queueDock) {
queueDock.style.display = isReader ? 'none' : 'flex';}
if (document.querySelector('.queue-dock')) return;
const dock = document.createElement('div');
dock.className = 'queue-dock';
dock.innerHTML = `
<div class="queue-panel">
<div class="queue-header">
<div class="queue-header-actions">
<span class="queue-btn-action queue-edit-mode">Delete Mode</span>
<a href="/?view=queue" class="queue-btn-action queue-view-full">View Page</a>
<span class="queue-btn-action queue-done is-hidden">Save Changes</span>
<span class="queue-btn-action queue-cancel is-hidden">Cancel</span>
<span class="queue-btn-action queue-clear is-hidden">Select All</span>
</div>
</div>
<ul class="queue-list"></ul>
<div class="queue-dock-pagination" style="display: none;"></div>
</div>
<div class="queue-toggle-btn" title="Toggle Queue">
<i class="fa fa-clock"></i>
<div class="queue-count">0</div>
</div>
`;
document.body.appendChild(dock);
const toggle = dock.querySelector('.queue-toggle-btn');
const panel = dock.querySelector('.queue-panel');
const list = dock.querySelector('.queue-list');
const paginationContainer = dock.querySelector('.queue-dock-pagination');
const viewBtn = dock.querySelector('.queue-view-full');
const editBtn = dock.querySelector('.queue-edit-mode');
const clearBtn = dock.querySelector('.queue-clear');
const doneBtn = dock.querySelector('.queue-done');
const cancelBtn = dock.querySelector('.queue-cancel');
let isEditingQueue = false;
const toggleEditMode = (forceState = null) => {
isEditingQueue = forceState !== null ? forceState : !isEditingQueue;
pendingQueueDeletes.clear();
list.classList.toggle('is-editing', isEditingQueue);
editBtn.classList.toggle('is-hidden', isEditingQueue);
viewBtn.classList.toggle('is-hidden', isEditingQueue);
clearBtn.classList.toggle('is-hidden', !isEditingQueue);
cancelBtn.classList.toggle('is-hidden', !isEditingQueue);
doneBtn.classList.add('is-hidden');
list.querySelectorAll('.queue-item.to-delete').forEach(item => {
item.classList.remove('to-delete');
});
};
editBtn.onclick = () => toggleEditMode(true);
cancelBtn.onclick = () => toggleEditMode(false);
doneBtn.onclick = () => {
if (!isEditingQueue || pendingQueueDeletes.size === 0) return;
readingQueue = readingQueue.filter(item =>
!pendingQueueDeletes.has(String(item.id))
);
toggleEditMode(false);
saveQueue();
if (isQueueView()) renderQueuePage();
};
clearBtn.onclick = () => {
if (!isEditingQueue) return;
readingQueue.forEach(item => pendingQueueDeletes.add(String(item.id)));
updateQueueWidget();
};
list.addEventListener('click', (e) => {
if (!isEditingQueue) return;
const itemEl = e.target.closest('.queue-item');
if (!itemEl) return;
e.preventDefault();
e.stopPropagation();
const id = itemEl.dataset.id;
if (pendingQueueDeletes.has(id)) pendingQueueDeletes.delete(id);
else pendingQueueDeletes.add(id);
itemEl.classList.toggle('to-delete', pendingQueueDeletes.has(id));
doneBtn.classList.toggle('is-hidden', pendingQueueDeletes.size === 0);
});
paginationContainer.addEventListener('click', (e) => {
if (e.target.closest('.queue-dock-prev')) {
if (dockCurrentPage > 1) {
dockCurrentPage--;
updateQueueWidget();
list.scrollTop = 0;
}
} else if (e.target.closest('.queue-dock-next')) {
const totalPages = Math.ceil(readingQueue.length / DOCK_ITEMS_PER_PAGE);
if (dockCurrentPage < totalPages) {
dockCurrentPage++;
updateQueueWidget();
list.scrollTop = 0;
}
}
});
let isScrollOnCooldown = false;
list.addEventListener('wheel', (e) => {
e.preventDefault();
if (isScrollOnCooldown) return;
const totalPages = Math.ceil(readingQueue.length / DOCK_ITEMS_PER_PAGE);
if (totalPages <= 1) return;
isScrollOnCooldown = true;
if (e.deltaY > 0) {
if (dockCurrentPage < totalPages) {
dockCurrentPage++;
updateQueueWidget();
}
} else if (e.deltaY < 0) {
if (dockCurrentPage > 1) {
dockCurrentPage--;
updateQueueWidget();
}
}
setTimeout(() => { isScrollOnCooldown = false; }, 150);
}, { passive: false });
let touchStartX = 0;
let touchStartY = 0;
list.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
list.addEventListener('touchend', (e) => {
const touchEndX = e.changedTouches[0].screenX;
const touchEndY = e.changedTouches[0].screenY;
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 40) {
const totalPages = Math.ceil(readingQueue.length / DOCK_ITEMS_PER_PAGE);
if (deltaX < 0) {
if (dockCurrentPage < totalPages) {
dockCurrentPage++;
updateQueueWidget();
}
} else {
if (dockCurrentPage > 1) {
dockCurrentPage--;
updateQueueWidget();
}
}
}
}, { passive: true });
window.addEventListener('storage', (e) => {
if (e.key === 'nhentai_queue_v1' && e.newValue) {
readingQueue = JSON.parse(e.newValue);
updateQueueWidget();
updateAllQueueButtons();
}
});
toggle.onclick = (e) => {
const isVisible = panel.classList.toggle('is-visible');
toggle.classList.toggle('active', isVisible);
if (!isVisible) toggleEditMode(false);
if (isVisible) {
const currentId = window.location.pathname.match(/\/g\/(\d+)/)?.[1];
if (currentId) {
const itemIndex = readingQueue.findIndex(item => item.id === currentId);
if (itemIndex > -1) {
const targetPage = Math.floor(itemIndex / DOCK_ITEMS_PER_PAGE) + 1;
if (dockCurrentPage !== targetPage) {
dockCurrentPage = targetPage;
updateQueueWidget();
}
}
}
}
};
updateQueueWidget();
}
const PAGTPL_KEY = 'nhentai_flow_pagtpl';
let pagTpl = null;
pagTpl = readJSON(PAGTPL_KEY, null);
const PAG_ICON = {
first: '<i class="fa fa-chevron-left"></i><i class="fa fa-chevron-left"></i>',
previous: '<i class="fa fa-chevron-left"></i>',
next: '<i class="fa fa-chevron-right"></i>',
last: '<i class="fa fa-chevron-right"></i><i class="fa fa-chevron-right"></i>'
};
function pagSig() {
return pagTpl && pagTpl.sections && pagTpl.sections.length
? pagTpl.sections.map(x => x.tag + '.' + x.className).join('|')
: 'fallback';
}
function captureNativePagination() {
const secs = Array.from(document.querySelectorAll(SEL.pagination)).filter(el => !el.closest('#nf-queue-root'));
if (!secs.length) return;
const tpl = { sections: [], roles: Object.assign({}, pagTpl && pagTpl.roles) };
secs.forEach(sec => {
tpl.sections.push({ tag: sec.tagName.toLowerCase(), className: sec.className });
sec.querySelectorAll('a, span, button').forEach(el => {
const c = el.classList;
let role = null;
if (c.contains('first')) role = 'first';
else if (c.contains('previous')) role = 'previous';
else if (c.contains('next')) role = 'next';
else if (c.contains('last')) role = 'last';
else if (c.contains('page')) role = c.contains('current') ? 'current' : 'page';
if (!role) return;
let cls = String(el.className);
if (role === 'page') cls = cls.split(/\s+/).filter(t => t && t !== 'current').join(' ');
tpl.roles[role] = {
tag: el.tagName.toLowerCase(),
cls,
html: (role === 'page' || role === 'current') ? null : el.innerHTML
};
});
});
const before = JSON.stringify(pagTpl);
const after = JSON.stringify(tpl);
if (before !== after) {
pagTpl = tpl;
try { localStorage.setItem(PAGTPL_KEY, after); } catch (e) { /* ignore */ }
}
}
function pagRole(role) {
const R = (pagTpl && pagTpl.roles) || {};
if (R[role]) return { tag: R[role].tag, cls: R[role].cls, html: R[role].html };
const strip = (cls, tok) => cls.split(/\s+/).filter(t => t && t !== tok).join(' ');
const swap = (cls, from, to) => cls.split(/\s+/).map(t => (t === from ? to : t)).join(' ');
if (role === 'current' && R.page) return { tag: R.page.tag, cls: R.page.cls + ' current', html: null };
if (role === 'page' && R.current) return { tag: R.current.tag, cls: strip(R.current.cls, 'current'), html: null };
const sources = { first: ['previous', 'last', 'next'], previous: ['next', 'first', 'last'], next: ['previous', 'last', 'first'], last: ['next', 'first', 'previous'] };
for (const src of (sources[role] || [])) {
if (R[src]) return { tag: R[src].tag, cls: swap(R[src].cls, src, role), html: null };
}
return { tag: 'a', cls: role === 'current' ? 'page current' : role, html: null };
}
function createPagination(totalItems, currentPage, itemsPerPage) {
const totalPages = Math.ceil(totalItems / itemsPerPage);
if (totalPages <= 1) return null;
const hasTpl = !!(pagTpl && pagTpl.sections && pagTpl.sections.length);
const defs = hasTpl
? pagTpl.sections
: [{ tag: 'section', className: 'pagination desktop-pagination nf-pag-fallback' }];
const frag = document.createDocumentFragment();
const href = (p) => `/?view=queue&page=${p}`;
defs.forEach(def => {
const sec = document.createElement(def.tag);
sec.className = def.className;
const add = (role, p, label) => {
const d = pagRole(role);
const el = document.createElement(d.tag);
el.className = d.cls;
if (d.tag === 'a') el.href = href(p);
el.innerHTML = d.html != null ? d.html : (PAG_ICON[role] || label);
sec.appendChild(el);
};
if (currentPage > 1) {
add('first', 1);
add('previous', currentPage - 1);
}
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || (i >= currentPage - 5 && i <= currentPage + 5)) {
add(i === currentPage ? 'current' : 'page', i, String(i));
}
}
if (currentPage < totalPages) {
add('next', currentPage + 1);
add('last', totalPages);
}
frag.appendChild(sec);
});
return frag;
}
function applyQueueSort(mode) {
currentSortMode = mode;
localStorage.setItem('nhentai_queue_sort', mode);
if (mode === 'shuffle') {
for (let i = readingQueue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[readingQueue[i], readingQueue[j]] = [readingQueue[j], readingQueue[i]];
}
} else if (mode === 'newest') {
readingQueue.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
} else if (mode === 'oldest') {
readingQueue.sort((a, b) => (a.addedAt || 0) - (b.addedAt || 0));
}
saveQueue();
renderQueuePage();
}
function renderQueuePage() {
const params = new URLSearchParams(window.location.search);
if (params.get('view') !== 'queue') return;
document.title = "Reading Queue";
const content = getQueueRoot(true);
if (!content) return;
content.innerHTML = '';
content.dataset.rendered = window.location.search;
content.dataset.pagSig = pagSig();
const ITEMS_PER_PAGE = 25;
const currentPage = parseInt(params.get('page')) || 1;
const totalItems = readingQueue.length;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentItems = readingQueue.slice(startIndex, endIndex);
const header = document.createElement('div');
header.innerHTML = `
<h1>
<span>
<i class="fa fa-clock color-icon"></i> Reading queue
</span>
<span class="count">(${totalItems})</span>
</h1>
`;
content.appendChild(header);
if (totalItems >= 0) {
const toolbar = document.createElement('div');
toolbar.className = 'queue-toolbar';
const leftGroup = document.createElement('div');
leftGroup.className = 'queue-group';
leftGroup.className = 'queue-group segmented';
const rightGroup = document.createElement('div');
rightGroup.className = 'queue-group right';
const buttons = [
{ id: 'newest', label: 'newest' },
{ id: 'oldest', label: 'oldest' },
{ id: 'shuffle', label: 'shuffle' }
];
if (totalItems > 1) {
const sortLabel = document.createElement('span');
sortLabel.className = 'q-sort-label';
sortLabel.textContent = 'Sort:';
leftGroup.appendChild(sortLabel);
buttons.forEach(btn => {
const buttonEl = document.createElement('button');
const isActive = currentSortMode === btn.id ? 'active' : '';
buttonEl.className = `btn-q-tool ${isActive}`;
buttonEl.setAttribute('data-text', btn.label);
buttonEl.innerHTML = `${btn.label}`;
buttonEl.onclick = () => applyQueueSort(btn.id);
leftGroup.appendChild(buttonEl);
});
}
const discoverBtn = document.createElement('button');
discoverBtn.className = 'q-discover';
discoverBtn.innerHTML = 'Discover & Fill';
discoverBtn.title = "Let the script pick new galleries for your queue based on your favorites.";
discoverBtn.onclick = async (e) => {
const originalText = 'Discover & Fill';
const allButtons = toolbar.querySelectorAll('button');
allButtons.forEach(btn => {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
});
discoverBtn.style.opacity = '1';
try {
await runDiscoverFlow((status) => {
discoverBtn.innerHTML = `${status}`;
});
discoverBtn.innerHTML = 'Completed';
discoverBtn.style.background = '#3c763d';
setTimeout(() => {
renderQueuePage()
}, 1000);
} catch (err) {
console.error(err);
discoverBtn.innerHTML = /Not logged in/.test(err && err.message) ? 'Login required' : 'Error: ' + String((err && err.message) || 'unknown').slice(0, 40);
discoverBtn.style.background = '#a94442';
setTimeout(() => {
discoverBtn.innerHTML = originalText;
discoverBtn.style.background = 'linear-gradient(45deg, #5b26ed, #ed2553)';
allButtons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
});
}, 3000);
}
};
rightGroup.appendChild(discoverBtn);
toolbar.appendChild(leftGroup);
toolbar.appendChild(rightGroup);
content.appendChild(toolbar);
}
const grid = document.createElement('div');
grid.className = SITE_CLASS.grid;
if (totalItems === 0) {
grid.innerHTML = '<div style="padding: 50px; text-align: center; font-size: 18px; color: #666;">Your queue is empty.<p>Time to find some inspiration for the task ahead.</p></div>';
} else {
const fragment = document.createDocumentFragment();
currentItems.forEach(item => {
const galleryDiv = document.createElement('div');
galleryDiv.className = 'gallery';
galleryDiv.setAttribute('data-gid', item.id);
const defaultPadding = '145.6%';
galleryDiv.innerHTML = `
<a href="${item.galleryUrl}" class="cover" style="padding:0 0 ${defaultPadding} 0; position: relative; display: block;">
<img class="lazyload"
src="${item.coverUrl}"
data-src="${item.coverUrl}"
loading="lazy"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;" />
<div class="caption">${item.title}</div>
</a>
`;
const img = galleryDiv.querySelector('img');
const setRatio = function() {
if (this.naturalWidth && this.naturalHeight) {
const aspectRatio = (this.naturalHeight / this.naturalWidth) * 100;
const coverLink = galleryDiv.querySelector('.cover');
coverLink.style.padding = `0 0 ${aspectRatio}% 0`;
this.onload = null;
}
};
img.onload = setRatio;
if (img.complete) setRatio.call(img);
fragment.appendChild(galleryDiv);
});
grid.appendChild(fragment);
}
content.appendChild(grid);
if (totalItems > ITEMS_PER_PAGE) {
const pagination = createPagination(totalItems, currentPage, ITEMS_PER_PAGE);
if (pagination) content.appendChild(pagination);
}
grid.querySelectorAll(SEL.gallery).forEach(initPreviewUI);
}
function areTitlesSimilar(title1, title2) {
const tokenize = (str) => {
return new Set(
str.toLowerCase()
.replace(/[([{].*?[)\]}]/g, '')
.replace(/[^\w\s]|_/g, "")
.split(/\s+/)
.filter(w => w.length > 2)
);
};
const t1 = tokenize(title1);
const t2 = tokenize(title2);
if (t1.size < 2 || t2.size < 2) return false;
let matches = 0;
t1.forEach(word => {
if (t2.has(word)) matches++;
});
const minSize = Math.min(t1.size, t2.size);
const similarity = matches / minSize;
return similarity >= 0.75;
}
async function runDiscoverFlow(updateStatus) {
const IGNORE_TAGS = [
'sole male', 'sole female', 'males only', 'big breasts', 'nakadashi', 'ahegao',
'full color', 'full censorship', 'uncensored', 'mosaic censorship',
];
updateStatus("Processing…");
let first;
try {
first = await apiJson('/api/v2/favorites?page=1&q=');
} catch (e) {
if (e.status === 401 || e.status === 403) throw new Error("Not logged in");
throw e;
}
const totalPages = pagesFromApi(first);
let favList = listFromApi(first);
if (totalPages > 1) {
const randomPage = Math.floor(Math.random() * totalPages) + 1;
if (randomPage > 1) {
favList = listFromApi(await apiJson(`/api/v2/favorites?page=${randomPage}&q=`));
}
}
const favGalleries = favList.map(g => String(g.id)).filter(Boolean);
if (favGalleries.length === 0) throw new Error("No favorites found on this page");
const sampleSize = Math.min(5, favGalleries.length);
const shuffledFavs = favGalleries.sort(() => 0.5 - Math.random()).slice(0, sampleSize);
const tagCounts = {};
for (const id of shuffledFavs) {
const meta = await getMeta(id);
if (meta && meta.tags) {
meta.tags.forEach(t => {
if (t.type === 'tag' && !IGNORE_TAGS.includes(t.name)) {
tagCounts[t.name] = (tagCounts[t.name] || 0) + 1;
}
});
}
await new Promise(r => setTimeout(r, 350));
}
const sortedTags = Object.entries(tagCounts)
.sort(([,a], [,b]) => b - a)
.map(([tag]) => tag);
if (sortedTags.length === 0) throw new Error("Not enough data");
const topTags = sortedTags.slice(0, 2);
const spiceTag = sortedTags.slice(2, 10)[Math.floor(Math.random() * Math.min(8, sortedTags.length - 2))];
if (spiceTag) topTags.push(spiceTag);
const queryStr = topTags.map(t => `tag:"${t}"`).join(' ');
const [candidatesWeek, candidatesAll] = await Promise.all([
searchGalleriesApi(`${queryStr} uploaded:>6d`, 'popular-week'),
searchGalleriesApi(queryStr, 'popular')
]);
const newItems = [];
const processedIds = new Set();
readingQueue.forEach(q => processedIds.add(String(q.id)));
const tryAddCandidate = async (candidate) => {
if (!candidate) return false;
if (processedIds.has(String(candidate.id))) return false;
const allTitles = [...readingQueue, ...newItems];
const isTitleDup = allTitles.some(item => areTitlesSimilar(item.title, candidate.title));
if (isTitleDup) {
return false;
}
processedIds.add(String(candidate.id));
const isFav = favCache.has(String(candidate.id));
if (isFav) {
return false;
}
newItems.push(candidate);
return true;
};
let weekAdded = 0;
let attempts = 0;
while (newItems.length < 5 && attempts < 30) {
attempts++;
let success = false;
if (weekAdded < 3 && candidatesWeek.length > 0) {
success = await tryAddCandidate(candidatesWeek.shift());
if (success) weekAdded++;
} else if (candidatesAll.length > 0) {
success = await tryAddCandidate(candidatesAll.shift());
} else if (candidatesWeek.length > 0) {
success = await tryAddCandidate(candidatesWeek.shift());
if (success) weekAdded++;
} else {
break;
}
}
if (newItems.length === 0) throw new Error("No new galleries found");
newItems.forEach(item => {
toggleQueueItem(item.id, item.title, item.coverUrl, item.galleryUrl);
});
await new Promise(r => setTimeout(r, 1000));
}
// ==========================================================================
// 3. FAVORITES MANAGER
// ==========================================================================
function saveFavCache() {
localStorage.setItem('nhentai_flow_favs', JSON.stringify([...favCache]));
}
function reloadFavCacheFromStorage() {
try {
favCache = new Set(readList('nhentai_flow_favs'));
updateAllMarkers();
} catch (e) { /* ignore */ }
}
window.addEventListener('storage', (e) => {
if (e.key === 'nhentai_flow_favs') reloadFavCacheFromStorage();
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) reloadFavCacheFromStorage();
});
function addFav(id) {
if (!favCache.has(String(id))) {
favCache.add(String(id));
saveFavCache();
updateAllMarkers();
}
}
const recentlyRemoved = new Map();
function removeFav(id) {
recentlyRemoved.set(String(id), Date.now());
if (favCache.has(String(id))) {
favCache.delete(String(id));
saveFavCache();
updateAllMarkers();
}
}
function updateAllMarkers() {
document.querySelectorAll(SEL.gallery).forEach(g => {
if (g.updateMarkersFn) g.updateMarkersFn();
});
}
// ==========================================================================
// CDN: servers come from GET /api/v2/cdn (docs: never hardcode subdomains)
// ==========================================================================
const CDN_KEY = 'nhentai_flow_cdn';
const CDN_TTL = 6 * 60 * 60 * 1000;
const CDN_FALLBACK = { images: ['https://i.nhentai.net'], thumbs: ['https://t.nhentai.net'] };
let cdnState = null;
let cdnPromise = null;
try {
const raw = JSON.parse(localStorage.getItem(CDN_KEY) || 'null');
if (raw && Array.isArray(raw.images) && Array.isArray(raw.thumbs)) cdnState = raw;
} catch (e) { /* ignore */ }
function cdnHost(h) {
h = String(h).trim();
if (!h) return '';
if (h.startsWith('//')) h = 'https:' + h;
if (!/^https?:\/\//.test(h)) h = 'https://' + h;
return h.replace(/\/+$/, '');
}
function parseCdn(json) {
const images = [], thumbs = [];
const push = (val, hint) => {
if (typeof val === 'string') {
const host = cdnHost(val);
if (!host) return;
const isThumb = hint === 'thumb' || (hint !== 'image' && /\/\/t\d*\./.test(host));
(isThumb ? thumbs : images).push(host);
} else if (Array.isArray(val)) {
val.forEach(v => push(v, hint));
} else if (val && typeof val === 'object') {
const direct = val.url || val.host || val.base_url || val.domain || val.server;
if (direct) { push(direct, hint); return; }
Object.keys(val).forEach(k => {
const h = /thumb|cover/i.test(k) ? 'thumb' : (/image|media|page|gallery/i.test(k) ? 'image' : hint);
push(val[k], h);
});
}
};
push(json, null);
return { images: [...new Set(images)], thumbs: [...new Set(thumbs)] };
}
let cdnFailUntil = 0;
function ensureCdn() {
if (cdnState && Date.now() - (cdnState.ts || 0) < CDN_TTL) return Promise.resolve(cdnState);
if (cdnPromise) return cdnPromise;
if (Date.now() < cdnFailUntil) return Promise.resolve(cdnState || CDN_FALLBACK);
cdnPromise = (async () => {
try {
const json = await apiJson('/api/v2/cdn');
const parsed = (json && (Array.isArray(json.image_servers) || Array.isArray(json.thumb_servers)))
? {
images: [...new Set((json.image_servers || []).map(cdnHost).filter(Boolean))],
thumbs: [...new Set((json.thumb_servers || []).map(cdnHost).filter(Boolean))]
}
: parseCdn(json);
if (parsed.images.length || parsed.thumbs.length) {
cdnState = {
ts: Date.now(),
images: parsed.images.length ? parsed.images : CDN_FALLBACK.images,
thumbs: parsed.thumbs.length ? parsed.thumbs : (parsed.images.length ? parsed.images : CDN_FALLBACK.thumbs)
};
try { localStorage.setItem(CDN_KEY, JSON.stringify(cdnState)); } catch (e) { /* ignore */ }
} else {
console.warn('[NHentai Flow] /api/v2/cdn: unrecognized response, using fallback', json);
}
} catch (e) {
console.warn('[NHentai Flow] CDN list unavailable, using fallback:', e.message || e);
}
if (!(cdnState && Date.now() - (cdnState.ts || 0) < CDN_TTL)) cdnFailUntil = Date.now() + 60000;
cdnPromise = null;
return cdnState || CDN_FALLBACK;
})();
return cdnPromise;
}
function pickServer(list, path) {
let h = 0;
for (let i = 0; i < path.length; i++) h = (h * 31 + path.charCodeAt(i)) >>> 0;
return list[h % list.length];
}
function cdnUrl(kind, path) {
const p = String(path || '').replace(/^\//, '');
if (!p) return '';
const st = cdnState || CDN_FALLBACK;
const list = (kind === 'thumb' ? st.thumbs : st.images) || [];
return `${list.length ? pickServer(list, p) : CDN_FALLBACK[kind === 'thumb' ? 'thumbs' : 'images'][0]}/${p}`;
}
function getAccessToken() {
const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
if (m) return decodeURIComponent(m[1]);
for (const store of [window.localStorage, window.sessionStorage]) {
try {
for (let i = 0; i < store.length; i++) {
const k = store.key(i);
if (/access[_-]?token/i.test(k)) {
const v = store.getItem(k);
if (v) return v.replace(/^"|"$/g, '');
}
}
} catch (e) { /* ignore */ }
}
return null;
}
function authHeaders() {
const token = getAccessToken();
return token ? { 'Authorization': 'User ' + token } : {};
}
const RL_WINDOW = 60000;
const RL_MAX_WAIT = 90000;
const RL_COOLDOWN_KEY = 'nhentai_flow_cooldown';
const RL_LIMITS = {
// [anonymous, logged in] documented limit (anonymous / logged in)
meta: [12, 28], // GET /galleries/{id} 20 / 45
list: [8, 18], // GET /galleries, /tagged, /random 15 / 30
search: [6, 12], // GET /search 10 / 20
favs: [10, 10], // GET /favorites, /favorites/random 15 per user
comments: [7, 12], // GET /galleries/{id}/comments/count 12 / 20
cdn: [4, 4] // GET /cdn
};
function rlBucket(url) {
const p = String(url).split('?')[0];
if (/\/api\/v2\/galleries\/\d+\/comments\/count$/.test(p)) return 'comments';
if (/\/api\/v2\/galleries\/\d+$/.test(p)) return 'meta';
if (/\/api\/v2\/galleries(\/tagged|\/popular|\/random)?$/.test(p)) return 'list';
if (/\/api\/v2\/search$/.test(p)) return 'search';
if (/\/api\/v2\/favorites(\/random)?$/.test(p)) return 'favs';
if (/\/api\/v2\/cdn$/.test(p)) return 'cdn';
return null;
}
function rlMax(bucket) {
const lim = RL_LIMITS[bucket];
if (!lim) return Infinity;
return getAccessToken() ? lim[1] : lim[0];
}
function rlCooldownLeft() {
try { return Math.max(0, (Number(localStorage.getItem(RL_COOLDOWN_KEY)) || 0) - Date.now()); } catch (e) { return 0; }
}
function rlSetCooldown(ms) {
try {
const until = Date.now() + ms;
if (until > (Number(localStorage.getItem(RL_COOLDOWN_KEY)) || 0)) localStorage.setItem(RL_COOLDOWN_KEY, String(until));
} catch (e) { /* ignore */ }
}
async function rlAcquire(bucket, wait) {
if (!bucket) return true;
const key = 'nhentai_flow_rl_' + bucket;
const max = rlMax(bucket);
const deadline = Date.now() + RL_MAX_WAIT;
for (;;) {
const cool = rlCooldownLeft();
if (cool > 0) {
if (!wait || Date.now() + cool > deadline) return false;
await sleep(cool + Math.random() * 300);
continue;
}
const now = Date.now();
const stamps = readList(key).filter(t => typeof t === 'number' && now - t < RL_WINDOW);
if (stamps.length < max) {
stamps.push(now);
try { localStorage.setItem(key, JSON.stringify(stamps)); } catch (e) { /* ignore */ }
return true;
}
if (!wait) return false;
const delay = RL_WINDOW - (now - stamps[0]) + 100 + Math.random() * 300;
if (now + delay > deadline) return false;
await sleep(delay);
}
}
async function fetchRetry(url, opts, retries, rl) {
const max = retries === undefined ? 3 : retries;
const wait = !(rl && rl.wait === false);
const bucket = rlBucket(url);
for (let attempt = 0; ; attempt++) {
if (!(await rlAcquire(bucket, wait))) {
const err = new Error('No free request slot');
err.rateLimited = true;
throw err;
}
const res = await fetch(url, opts);
if (res.status !== 429) return res;
const ra = parseFloat(res.headers.get('Retry-After'));
const backoff = !isNaN(ra) && ra > 0 ? ra * 1000 : 10000 * Math.pow(2, attempt);
rlSetCooldown(Math.min(backoff, 120000) + Math.random() * 1000);
if (!wait || attempt >= max) return res;
}
}
async function apiJson(url, opts, rl) {
const o = opts || {};
const res = await fetchRetry(url, Object.assign({}, o, {
credentials: 'same-origin',
headers: Object.assign({ 'Accept': 'application/json' }, authHeaders(), o.headers || {})
}), undefined, rl);
if (!res.ok) {
const err = new Error('HTTP ' + res.status);
err.status = res.status;
throw err;
}
return res.json();
}
function listFromApi(json) {
if (Array.isArray(json)) return json;
if (!json) return [];
return json.result || json.results || json.favorites || json.galleries || json.data || [];
}
function pagesFromApi(json) {
if (!json) return 1;
const n = json.num_pages || json.total_pages || json.pages;
if (n) return parseInt(n, 10) || 1;
if (json.total && json.per_page) return Math.max(1, Math.ceil(json.total / json.per_page));
return 1;
}
function normalizeApiGallery(g) {
const id = String(g.id);
const t = g.title;
const title = g.english_title || g.japanese_title ||
(typeof t === 'string' ? t : (t && (t.english || t.pretty || t.japanese))) || ('#' + id);
let thumb = g.thumbnail || g.thumb || (g.cover && (g.cover.path || g.cover)) ||
(g.images && g.images.cover && g.images.cover.path) || '';
if (thumb && typeof thumb === 'object') thumb = thumb.path || '';
if (thumb && !/^https?:/.test(thumb)) thumb = cdnUrl('thumb', thumb);
return { id, title, coverUrl: thumb, galleryUrl: `${location.origin}/g/${id}/` };
}
async function searchGalleriesApi(queryStr, sort) {
const json = await apiJson(`/api/v2/search?query=${encodeURIComponent(queryStr)}&sort=${sort}&page=1`);
return listFromApi(json).map(normalizeApiGallery);
}
function initFavoritesPage() {
if (!/^\/(?:user\/)?favorites\/?$/.test(window.location.pathname)) return;
let added = 0;
document.querySelectorAll(SEL.removeButton).forEach(btn => {
const id = cardIdFrom(btn);
if (!id) return;
const removedAt = recentlyRemoved.get(String(id));
if (removedAt && Date.now() - removedAt < 30000) return;
if (!favCache.has(String(id))) {
favCache.add(String(id));
added++;
}
});
if (added > 0) {
saveFavCache();
updateAllMarkers();
}
}
async function syncFavorites(updateStatus) {
const report = typeof updateStatus === 'function' ? updateStatus : () => {};
let first;
try {
first = await apiJson('/api/v2/favorites?page=1&q=');
} catch (e) {
if (e.status === 401 || e.status === 403) throw new Error('Login required');
throw e;
}
const totalPages = pagesFromApi(first);
const ids = new Set(listFromApi(first).map(g => String(g.id)));
for (let i = 2; i <= totalPages; i++) {
report(`${i}/${totalPages} (${ids.size} IDs)`);
await sleep(6500);
const json = await apiJson(`/api/v2/favorites?page=${i}&q=`);
listFromApi(json).forEach(g => ids.add(String(g.id)));
}
favCache = ids;
saveFavCache();
updateAllMarkers();
return ids.size;
}
// ==========================================================================
// 4. PREVIEW LOGIC
// ==========================================================================
const metaInflight = new Map();
const metaFail = new Map();
const META_STORE_TTL = 30 * 60 * 1000;
const META_STORE_MAX = 40;
function metaStoreGet(id) {
try {
const raw = sessionStorage.getItem('nf_meta_' + id);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || !o.meta || Date.now() - o.ts > META_STORE_TTL) { sessionStorage.removeItem('nf_meta_' + id); return null; }
return o.meta;
} catch (e) { return null; }
}
function metaStorePut(id, meta) {
try {
const idx = JSON.parse(sessionStorage.getItem('nf_meta_idx') || '[]').filter(x => x !== String(id));
idx.push(String(id));
while (idx.length > META_STORE_MAX) sessionStorage.removeItem('nf_meta_' + idx.shift());
sessionStorage.setItem('nf_meta_' + id, JSON.stringify({ ts: Date.now(), meta }));
sessionStorage.setItem('nf_meta_idx', JSON.stringify(idx));
} catch (e) { /* quota or storage disabled: the in-memory cache still works */ }
}
function getMeta(id, force, rl) {
if (!force && metaCache.has(id)) return Promise.resolve(metaCache.get(id));
if (!force) {
const stored = metaStoreGet(id);
if (stored) { metaCache.set(id, stored); return Promise.resolve(stored); }
}
if (metaInflight.has(id)) return metaInflight.get(id);
if ((metaFail.get(id) || 0) > Date.now()) return Promise.resolve(null);
const p = fetchMeta(id, rl).finally(() => metaInflight.delete(id));
metaInflight.set(id, p);
return p;
}
async function fetchMeta(id, rl) {
try {
await ensureCdn();
const response = await fetchRetry(`/api/v2/galleries/${id}`, { credentials: 'same-origin', headers: Object.assign({ 'Accept': 'application/json' }, authHeaders()) }, undefined, rl);
if (!response.ok) throw new Error(`Erro na API: ${response.status}`);
const data = await response.json();
let coverPath = '';
if (data.cover && data.cover.path) coverPath = data.cover.path;
else if (data.images && data.images.cover && data.images.cover.path) coverPath = data.images.cover.path;
const coverUrl = coverPath ? cdnUrl('thumb', coverPath) : '';
let pagesArray = [];
if (data.pages && Array.isArray(data.pages)) pagesArray = data.pages;
else if (data.images && data.images.pages && Array.isArray(data.images.pages)) pagesArray = data.images.pages;
const meta = {
id: data.id,
media_id: data.media_id,
title: data.title.english || data.title.pretty || data.title.japanese,
cover: coverUrl,
tags: data.tags || [],
total: data.num_pages || 1,
pagesData: pagesArray
};
metaCache.set(id, meta);
metaStorePut(id, meta);
return meta;
} catch (error) {
if (!error.rateLimited) {
metaFail.set(id, Date.now() + 60000);
console.error('[NHentai Flow] Failure to load preview via API v2 to ID', id, error);
}
return null;
}
}
function buildTagList(tags) {
const groups = { artist: [], parody: [], character: [], tag: [] };
const fmt = (n) => n >= 1000 ? (n/1000).toFixed(1) + 'k' : n;
const getTier = (c) => {
if (c < 1000) return 'tier-mythic';
if (c < 5000) return 'tier-rare';
if (c < 20000) return 'tier-uncommon';
return '';
};
const getGenreStyle = (name) => {
if (['yaoi', 'males only', 'bara', 'yuri', 'females only', 'lesbian', 'futanari', 'tomgirl', 'otokonoko', 'dickgirl', 'shemale', 'bisexual'].includes(name)) return 'style-lgbt';
return '';
};
tags.forEach(t => {
const count = t.count || 0;
let className = '';
if (t.type === 'tag') {
className = `${getTier(count)} ${getGenreStyle(t.name)}`;
}
const html = `<span class="tag-pill ${className}" title="${t.name} (${fmt(count)})">${t.name}</span>`;
if (groups[t.type]) groups[t.type].push(html);
else if (t.type === 'group') groups.artist.push(`<span class="tag-pill">[${t.name}]</span>`);
});
let html = '';
const addGroup = (title, list) => { if (list.length) html += `<div class="tag-category">${title}</div>` + list.join(''); };
addGroup('Artists', groups.artist); addGroup('Parodies', groups.parody); addGroup('Characters', groups.character); addGroup('Tags', groups.tag);
return html || '<div style="padding:5px">No tags</div>';
}
function update(gallery, val, isJump = false, fromHover = false) {
if (isStaleCard(gallery)) { initPreviewUI(gallery); return Promise.resolve(); }
const id = gallery.dataset.gid;
const state = states.get(id) || { curr: 1, req: 0 };
states.set(id, state);
return getMeta(id, false, fromHover ? { wait: false } : undefined).then(meta => {
if (!meta) return;
let next = isJump ? val : state.curr + val;
if (next < 1) next = 1; if (next > meta.total) next = meta.total;
const popup = gallery.querySelector('.tag-popup');
if (popup && !popup.innerHTML) {
try {
popup.innerHTML = buildTagList(meta.tags);
} catch (e) {
console.error('[NHentai Flow] Erro isolado ao construir tags:', e);
}
}
if (next === state.curr && !isJump && val !== 0) return;
state.curr = next;
const reqId = ++state.req;
if (state.curr !== 1) gallery.classList.add('is-previewing');
const barFill = gallery.querySelector('.seek-fill');
if (barFill) barFill.style.transform = `scaleX(${state.curr / meta.total})`;
let src = '';
let aspectRatio = '';
if (meta.pagesData && meta.pagesData[state.curr - 1]) {
const pageData = meta.pagesData[state.curr - 1];
if (pageData.path) {
src = cdnUrl('image', pageData.path);
aspectRatio = `${pageData.width}/${pageData.height}`;
}
}
if (!src) return;
const img = gallery.querySelector(SEL.coverImage);
if (!img) return;
if (!img.dataset.originalSrc) {
img.dataset.originalSrc = img.src;
}
if (state.loader) { state.loader.onload = null; state.loader.onerror = null; state.loader.src = ''; }
const loader = new Image();
state.loader = loader;
loader.onload = () => {
if (state.req === reqId && hoveredGallery === gallery && gallery.dataset.gid === id) {
if (aspectRatio) img.style.aspectRatio = aspectRatio;
img.src = src;
}
loader.onload = null;
loader.onerror = null;
};
loader.onerror = () => {
loader.onload = null;
loader.onerror = null;
};
loader.src = src;
});
}
function cardGalleryId(gallery) {
const link = gallery.querySelector(SEL.cover);
return { link, id: link ? link.href.match(/\/g\/(\d+)\//)?.[1] : null };
}
function isStaleCard(gallery) {
if (gallery.dataset.init !== '1') return false;
const cur = cardGalleryId(gallery);
return cur.link !== gallery._nfLink || cur.id !== gallery.dataset.gid;
}
function hasUnsyncedCards() {
const cards = document.querySelectorAll(SEL.gallery);
for (let i = 0; i < cards.length; i++) {
if (cards[i].dataset.init !== '1' || isStaleCard(cards[i])) return true;
}
return false;
}
function resetCard(gallery) {
if (hoveredGallery === gallery) hoveredGallery = null;
gallery.onmouseenter = null;
gallery.onmouseleave = null;
gallery.updateMarkersFn = null;
gallery._nfLink = null;
gallery.classList.remove('is-previewing');
gallery.style.width = '';
gallery.style.height = '';
gallery.removeAttribute('data-fixed-dimensions');
gallery.removeAttribute('data-init');
gallery.querySelectorAll('.reading-marker, .inline-preview-ui').forEach(m => m.remove());
const img = gallery.querySelector(SEL.coverImage);
if (img) { img.removeAttribute('data-original-src'); img.style.aspectRatio = ''; }
}
function initPreviewUI(gallery) {
if (isStaleCard(gallery)) resetCard(gallery);
const { link, id } = cardGalleryId(gallery);
if (!link || gallery.dataset.init === '1') return;
if (!id) return;
gallery.dataset.gid = id;
gallery._nfLink = link;
gallery.dataset.init = '1';
// 1. Visual Markers
const progressMarker = document.createElement('div');
progressMarker.className = 'reading-marker marker-progress marker-yellow';
progressMarker.style.display = 'none';
progressMarker.innerHTML = `<span class="fa-stack"><i class="fas fa-bookmark fa-stack-2x"></i><strong class="fa-stack-1x pg-num" style="color: #fff; font-size: 12px !important; margin-top: -3px;"></strong></span>`;
gallery.appendChild(progressMarker);
const queueMarker = document.createElement('div');
queueMarker.className = 'reading-marker marker-queue marker-blue';
queueMarker.style.display = 'none';
queueMarker.innerHTML = `<span class="fa-stack"><i class="fas fa-bookmark fa-stack-2x"></i><i class="fas fa-clock fa-stack-1x" style="color:#fff; font-size:15px; margin-top:-3px;"></i></span>`;
gallery.appendChild(queueMarker);
const favMarker = document.createElement('div');
favMarker.className = 'reading-marker marker-fav marker-pink';
favMarker.style.display = 'none';
favMarker.innerHTML = `<span class="fa-stack"><i class="fas fa-bookmark fa-stack-2x"></i><i class="fas fa-heart fa-stack-1x" style="color:#fff; font-size:15px; margin:-3px 0 0 0;"></i></span>`;
gallery.appendChild(favMarker);
const ctx = {
isQueue: new URLSearchParams(window.location.search).get('view') === 'queue',
isFavorites: window.location.pathname.includes('/favorites')
};
const updateMarkers = () => {
queueMarker.style.display = (!ctx.isQueue && settings.enableQueue && isQueued(id)) ? 'block' : 'none';
favMarker.style.display = (!ctx.isFavorites && favCache.has(String(id))) ? 'block' : 'none';
const state = states.get(id);
if (state?.curr > 1) {
progressMarker.style.display = 'block';
progressMarker.querySelector('.pg-num').textContent = state.curr;
} else {
progressMarker.style.display = 'none';
}
};
gallery.updateMarkersFn = updateMarkers;
updateMarkers();
let isUiInjected = false;
// 2. Hover Events
gallery.onmouseenter = () => {
if (isStaleCard(gallery)) {
initPreviewUI(gallery);
if (gallery.onmouseenter) gallery.onmouseenter();
return;
}
hoveredGallery = gallery;
if (!gallery.dataset.fixedDimensions) {
const rect = gallery.getBoundingClientRect();
gallery.style.width = `${rect.width}px`;
gallery.style.height = `${rect.height}px`;
gallery.dataset.fixedDimensions = '1';
}
if (!isUiInjected) {
const ui = document.createElement('div');
ui.className = 'inline-preview-ui';
ui.innerHTML = `
<div class="tag-trigger">TAGS</div>
<div class="tag-popup"></div>
<div class="queue-trigger" title="Add/Remove from Queue (Q)"><i class="fa fa-plus"></i></div>
<div class="hotzone hotzone-left"></div>
<div class="hotzone hotzone-right"></div>
<div class="seek-container"><div class="seek-bg"><div class="seek-fill"></div></div><div class="seek-tooltip">Pg 1</div></div>
`;
const qBtn = ui.querySelector('.queue-trigger');
if (isQueued(id)) { qBtn.classList.add('in-queue'); qBtn.innerHTML = '<i class="fa fa-check"></i>'; }
qBtn.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
const imgEl = gallery.querySelector(SEL.coverImage);
const coverUrl = imgEl.dataset.src || imgEl.dataset.originalSrc || imgEl.src;
const capEl = gallery.querySelector(SEL.caption);
const cardTitle = capEl ? capEl.textContent.trim() : '';
if (metaCache.has(id)) {
toggleQueueItem(id, metaCache.get(id).title, coverUrl, link.href);
} else if (cardTitle) {
toggleQueueItem(id, cardTitle, coverUrl, link.href);
} else {
qBtn.innerHTML = '<i class="fas fa-ellipsis-h"></i>';
getMeta(id).then(meta => {
if (!meta) { qBtn.innerHTML = '<i class="fa fa-exclamation"></i>'; return; }
toggleQueueItem(id, meta.title, coverUrl, link.href);
});
}
setTimeout(updateMarkers, 50);
};
const tagTrigger = ui.querySelector('.tag-trigger');
if (tagTrigger) {
tagTrigger.addEventListener('click', stopEvent);
tagTrigger.addEventListener('touchstart', stopPropOnly, { passive: true });
const loadTags = () => {
const pop = ui.querySelector('.tag-popup');
if (pop && !pop.innerHTML) update(gallery, 0);
};
tagTrigger.addEventListener('mouseenter', loadTags);
tagTrigger.addEventListener('touchstart', loadTags, { passive: true });
}
const tagPopup = ui.querySelector('.tag-popup');
if (tagPopup) {
tagPopup.addEventListener('click', stopEvent);
tagPopup.addEventListener('touchstart', stopPropOnly, { passive: true });
}
ui.querySelector('.hotzone-left').onclick = (e) => { stopEvent(e); update(gallery, -1); };
ui.querySelector('.hotzone-right').onclick = (e) => { stopEvent(e); update(gallery, 1); };
const seek = ui.querySelector('.seek-container');
const tip = ui.querySelector('.seek-tooltip');
let seekRect = null;
seek.onmouseenter = () => { seekRect = seek.getBoundingClientRect(); };
seek.onclick = (e) => {
stopEvent(e);
if (!seekRect) seekRect = seek.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - seekRect.left) / seekRect.width));
if (!metaCache.has(id)) {
update(gallery, 0).then(() => { update(gallery, Math.ceil(pct * metaCache.get(id).total) || 1, true); });
} else {
update(gallery, Math.ceil(pct * metaCache.get(id).total) || 1, true);
}
};
seek.onmousemove = (e) => {
if (!metaCache.has(id)) return;
if (!seekRect) seekRect = seek.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - seekRect.left) / seekRect.width));
tip.style.left = `${e.clientX - seekRect.left}px`;
tip.textContent = Math.ceil(pct * metaCache.get(id).total) || 1;
};
link.style.position = 'relative';
link.appendChild(ui);
isUiInjected = true;
}
if (!metaCache.has(id)) {
hoverTimeout = setTimeout(() => { update(gallery, 0, false, true); }, 300);
} else { update(gallery, 0); }
};
gallery.onmouseleave = () => {
hoveredGallery = null;
if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; }
gallery.classList.remove('is-previewing');
const img = link.querySelector('img');
if (img && img.dataset.originalSrc) {
img.src = img.dataset.originalSrc;
img.style.aspectRatio = '';
}
updateMarkers();
gallery.style.width = '';
gallery.style.height = '';
gallery.dataset.fixedDimensions = '';
};
}
// ==========================================================================
// 5. READER LOGIC
// ==========================================================================
let readerDirty = true;
let readerGlobalsInit = false;
function toggleReaderFullscreen() {
const c = document.querySelector(SEL.imageContainer);
if (!c) return;
if (!document.fullscreenElement) {
c.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
}
function extractGalleryId(json) {
if (json === null || json === undefined) return null;
if (typeof json === 'number' || (typeof json === 'string' && /^\d+$/.test(json))) return String(json);
if (typeof json !== 'object') return null;
for (const k of ['id', 'gallery_id', 'galleryId']) {
if (json[k] !== undefined && /^\d+$/.test(String(json[k]))) return String(json[k]);
}
for (const k of ['gallery', 'result', 'data']) {
const nested = extractGalleryId(json[k]);
if (nested) return nested;
}
return null;
}
async function getRandomFavoriteId() {
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
let noList = false;
try {
const rid = extractGalleryId(await apiJson('/api/v2/favorites/random'));
if (rid) return rid;
} catch (e) {
if (e.status === 401 || e.status === 403 || e.status === 404) noList = true;
else console.warn('[NHentai Flow] Random favorite endpoint failed:', e);
}
try {
if (noList) throw new Error('favorites list not available');
const first = await apiJson('/api/v2/favorites?page=1&q=');
const totalPages = pagesFromApi(first);
let list = listFromApi(first);
if (totalPages > 1) {
const p = Math.floor(Math.random() * totalPages) + 1;
if (p > 1) list = listFromApi(await apiJson(`/api/v2/favorites?page=${p}&q=`));
}
if (list.length) return String(pick(list).id);
} catch (e) {
console.warn('[NHentai Flow] Random favorite via API failed:', e);
}
if (favCache.size) return pick([...favCache]);
try {
const r = await fetch('/favorites/random');
const m = r.url.match(/\/g\/(\d+)/);
if (m) return m[1];
} catch (e) { /* ignore */ }
return null;
}
function initReaderMode() {
const imageContainer = document.querySelector(SEL.imageContainer);
if (!imageContainer) return;
if (!imageContainer.dataset.readerInit) {
imageContainer.dataset.readerInit = '1';
const exitIcon = document.createElement('div');
exitIcon.className = 'exit-fs-indicator';
exitIcon.innerHTML = '<i class="fa fa-times"></i>';
exitIcon.onclick = (e) => {
e.stopPropagation();
if (document.fullscreenElement) document.exitFullscreen();
};
imageContainer.appendChild(exitIcon);
imageContainer.addEventListener('click', (e) => {
if (e.target.tagName === 'IMG' || e.target.tagName === 'A') return;
const rect = imageContainer.getBoundingClientRect();
if (e.clientX - rect.left > rect.width / 2) {
const nextBtn = document.querySelector(SEL.readerNext);
if (nextBtn) nextBtn.click();
} else {
const prevBtn = document.querySelector(SEL.readerPrev);
if (prevBtn) prevBtn.click();
}
});
}
if (!readerGlobalsInit) {
readerGlobalsInit = true;
document.addEventListener('keydown', (e) => {
if (!isReader) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 't' || e.key === 'T') { e.preventDefault(); toggleReaderFullscreen(); }
});
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
const fsBtn = document.querySelector('.btn-fullscreen-custom');
if (fsBtn) fsBtn.innerHTML = '<i class="fa fa-expand"></i> Fullscreen';
const c = document.querySelector(SEL.imageContainer);
if (c) setTimeout(() => { c.scrollIntoView({ behavior: 'auto' }); });
}
});
let isWheeling = false;
document.addEventListener('wheel', (e) => {
if (!document.fullscreenElement) return;
e.preventDefault();
if (isWheeling) return;
isWheeling = true;
setTimeout(() => { isWheeling = false; }, 150);
if (e.deltaY > 0) {
const nextBtn = document.querySelector(SEL.readerNext);
if (nextBtn) nextBtn.click();
} else {
const prevBtn = document.querySelector(SEL.readerPrev);
if (prevBtn) prevBtn.click();
}
}, { passive: false });
}
const toolbars = document.querySelectorAll(SEL.readerBar);
if (toolbars.length === 0) return;
const bottomToolbar = toolbars[toolbars.length - 1];
const containerRight = bottomToolbar.querySelector(SEL.readerButtonsRight);
if (!containerRight) return;
// 1. Fullscreen
if (!containerRight.querySelector('.btn-fullscreen-custom')) {
const fsBtn = document.createElement('button');
fsBtn.className = 'btn btn-unstyled btn-fullscreen-custom';
fsBtn.innerHTML = '<i class="fa fa-expand"></i> Fullscreen';
fsBtn.title = "Toggle Fullscreen (T)";
fsBtn.style.marginRight = '5px';
fsBtn.onclick = toggleReaderFullscreen;
containerRight.insertBefore(fsBtn, containerRight.firstChild);
}
// 2. Next in queue (rebuilt every time, it depends on the current gallery)
document.querySelectorAll('.btn-next-queue').forEach(b => b.remove());
if (settings.enableQueue) {
const fsBtn = containerRight.querySelector('.btn-fullscreen-custom');
const currentId = window.location.pathname.match(/\/g\/(\d+)/)?.[1];
if (currentId && readingQueue.length > 0) {
const currentIndex = readingQueue.findIndex(i => i.id == currentId);
if (currentIndex > -1 && currentIndex < readingQueue.length - 1) {
const nextItem = readingQueue[currentIndex + 1];
const nextQBtn = document.createElement('a');
nextQBtn.className = 'btn btn-unstyled btn-next-queue';
nextQBtn.innerHTML = `<i class="fa fa-step-forward"></i> Next in queue`;
nextQBtn.href = `/g/${nextItem.id}/1/`;
nextQBtn.title = `Read Next in Queue: ${nextItem.title}`;
nextQBtn.setAttribute('data-sveltekit-reload', '');
nextQBtn.setAttribute('rel', 'external');
nextQBtn.addEventListener('click', (e) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
e.stopPropagation();
window.location.assign(nextQBtn.href);
});
if (fsBtn) {
fsBtn.parentNode.insertBefore(nextQBtn, fsBtn.nextSibling);
} else {
containerRight.appendChild(nextQBtn);
}
}
}
}
// 3. Random Favorite (picked on click, through the API)
const hasFavorites = !!document.querySelector(SEL.favoritesLink);
if (hasFavorites && !containerRight.querySelector('.btn-random-fav')) {
const randFavBtn = document.createElement('button');
randFavBtn.type = 'button';
randFavBtn.className = 'btn btn-unstyled btn-random-fav';
randFavBtn.innerHTML = '<i class="fa fa-random"></i> Fav';
randFavBtn.title = "Read Random Favorite";
randFavBtn.style.marginRight = '5px';
randFavBtn.onclick = async () => {
if (randFavBtn.dataset.busy) return;
randFavBtn.dataset.busy = '1';
randFavBtn.innerHTML = '<i class="fa fa-circle-notch fa-spin"></i>';
const id = await getRandomFavoriteId();
if (id) {
window.location.href = `/g/${id}/1/`;
} else {
randFavBtn.innerHTML = '<i class="fa fa-exclamation-triangle"></i>';
setTimeout(() => {
randFavBtn.innerHTML = '<i class="fa fa-random"></i> Fav';
delete randFavBtn.dataset.busy;
}, 2000);
}
};
containerRight.insertBefore(randFavBtn, containerRight.firstChild);
}
readerDirty = false;
}
// ==========================================================================
// 6. RANDOM CONTEXTUAL
// ==========================================================================
function currentListingQuery() {
const q = new URLSearchParams(window.location.search);
let query = q.get('q');
let sort = q.get('sort') || '';
if (query === null) {
const m = window.location.pathname.match(/^\/(tag|artist|character|parody|group|category|language)\/([^/]+)(?:\/(popular(?:-week|-today)?))?\/?$/);
if (m) {
query = `${m[1]}:"${decodeURIComponent(m[2]).replace(/-/g, ' ')}"`;
if (m[3]) sort = m[3];
}
}
return { query, sort };
}
async function randomListingGallery(page) {
const { query, sort } = currentListingQuery();
if (query === null) {
if (window.location.pathname !== '/') throw new Error('No query to search with');
const all = listFromApi(await apiJson(`/api/v2/galleries?page=${page}`));
if (!all.length) throw new Error('No galleries found');
return `${window.location.origin}/g/${all[Math.floor(Math.random() * all.length)].id}/`;
}
const url = `/api/v2/search?query=${encodeURIComponent(query)}${sort ? '&sort=' + encodeURIComponent(sort) : ''}&page=${page}`;
const list = listFromApi(await apiJson(url));
if (!list.length) throw new Error('No galleries found');
return `${window.location.origin}/g/${list[Math.floor(Math.random() * list.length)].id}/`;
}
function initRandomContextual() {
const sortContainer = document.querySelector(SEL.sortBar);
if (!sortContainer || sortContainer.querySelector('.btn-random-ctx')) return;
const btnContainer = document.createElement('div');
btnContainer.className = 'sort-type';
const btn = document.createElement('a');
btn.className = 'btn-random-ctx';
const ORIGINAL_HTML = '<i class="fa fa-random"></i>';
btn.innerHTML = ORIGINAL_HTML;
btn.style.cursor = 'pointer';
btn.title = "Roll a random gallery from these search results";
const resetBtn = () => {
btn.innerHTML = ORIGINAL_HTML;
btn.style.pointerEvents = 'auto';
btn.style.opacity = '1';
};
window.addEventListener('pageshow', (event) => {
if (event.persisted) { resetBtn(); }
});
resetBtn();
btn.onclick = async (e) => {
e.preventDefault();
btn.innerHTML = '<i class="fa fa-circle-notch fa-spin"></i>';
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.5';
try {
const lastPageBtn = document.querySelector(SEL.paginationLast);
let totalPages = 1;
if (lastPageBtn) {
const match = lastPageBtn.href.match(/page=(\d+)/);
if (match) totalPages = parseInt(match[1], 10);
} else {
const pages = document.querySelectorAll(SEL.paginationPage);
if (pages.length > 0) {
const lastNum = pages[pages.length - 1].textContent;
if (!isNaN(lastNum)) totalPages = parseInt(lastNum, 10);
}
}
if (totalPages === 1) {
const galleries = document.querySelectorAll(`${SEL.gallery} ${SEL.cover}`);
if (galleries.length === 0) throw new Error("No galleries found");
const randomGallery = galleries[Math.floor(Math.random() * galleries.length)];
window.location.href = randomGallery.href;
return;
}
const randomPage = Math.floor(Math.random() * totalPages) + 1;
window.location.href = await randomListingGallery(randomPage);
} catch (err) {
btn.innerHTML = '<i class="fa fa-exclamation-triangle"></i>';
setTimeout(() => { resetBtn(); }, 2000);
}
};
btnContainer.appendChild(btn);
sortContainer.appendChild(btnContainer);
}
// ==========================================================================
// 7. POPULAR SHORTCUT BUTTON
// ==========================================================================
function initPopularShortcut() {
const popularContainer = document.querySelector(SEL.popularBlock);
if (!popularContainer || popularContainer.querySelector('.btn-view-all-popular')) return;
const link = document.createElement('a');
link.href = '/search/?q=pages%3A%3E0&sort=popular-today';
link.className = 'btn btn-secondary btn-view-all-popular';
link.style.display = 'block';
link.style.marginTop = '5px';
link.innerHTML = '<i class="fa fa-compass"></i> Explore Today’s Trending';
popularContainer.appendChild(link);
}
// ==========================================================================
// 8. SMART NAVIGATION
// ==========================================================================
const SN_CFG = {
decayDelay: 2500,
decayFraction: 0.2,
bottomTolerance: 4,
scrollToTopOnNav: true,
cooldownAfterNav: 600,
touchBottomTolerance: 24
};
const SN = { installed: false, touching: false, touchX: 0, touchY: 0, progress: 0, lastWheel: 0, triggered: false, raf: null, lastFrame: 0, cooldownUntil: 0, bar: null, wrap: null };
function snThreshold() { return numSetting('smartNavScroll'); }
function snWheelPixels(e) {
return e.deltaY * (e.deltaMode === 1 ? 33 : (e.deltaMode === 2 ? window.innerHeight : 1));
}
function snEnabled() {
return !!settings.smartNav && !/^\/g\//.test(window.location.pathname);
}
function snRender() {
if (!SN.bar) return;
const pct = Math.min(100, (SN.progress / snThreshold()) * 100);
SN.bar.style.width = pct + '%';
SN.wrap.classList.toggle('active', SN.progress > 0);
}
function snReset() {
SN.progress = 0;
snRender();
}
function snAtBottom(tolerance) {
const tol = tolerance === undefined ? SN_CFG.bottomTolerance : tolerance;
const doc = document.documentElement;
const scrollTop = window.pageYOffset || doc.scrollTop || 0;
const fullHeight = Math.max(doc.scrollHeight, document.body.scrollHeight);
return scrollTop + window.innerHeight >= fullHeight - tol;
}
function snAccumulate(amount) {
if (!snFindNext()) return;
SN.lastWheel = performance.now();
SN.progress = Math.min(snThreshold(), SN.progress + amount);
snRender();
if (SN.raf === null) { SN.lastFrame = 0; SN.raf = requestAnimationFrame(snTick); }
if (SN.progress >= snThreshold()) snGo();
}
function snFindNext() {
const els = Array.from(document.querySelectorAll(SEL.paginationNext))
.filter(el => el.href && el.getClientRects().length > 0);
return els.length ? els[els.length - 1] : null;
}
function snTick(now) {
if (SN.triggered) {
SN.raf = null;
SN.lastFrame = 0;
return;
}
const dt = SN.lastFrame ? (now - SN.lastFrame) / 1000 : 0;
SN.lastFrame = now;
if (SN.progress > 0 && !SN.touching && performance.now() - SN.lastWheel > SN_CFG.decayDelay) {
SN.progress = Math.max(0, SN.progress - snThreshold() * SN_CFG.decayFraction * dt);
snRender();
}
if (SN.progress > 0) {
SN.raf = requestAnimationFrame(snTick);
} else {
SN.raf = null;
SN.lastFrame = 0;
}
}
function snGo() {
const next = snFindNext();
if (!next) { snReset(); return; }
SN.triggered = true;
SN.bar.style.width = '100%';
const urlBefore = window.location.href;
next.click();
setTimeout(() => {
if (SN.triggered && window.location.href === urlBefore) {
SN.triggered = false;
snReset();
}
}, 3000);
}
function smartNavOnRoute() {
if (!SN.installed) return;
const wasOurNavigation = SN.triggered;
SN.triggered = false;
SN.progress = 0;
if (SN.raf !== null) { cancelAnimationFrame(SN.raf); SN.raf = null; }
SN.lastFrame = 0;
SN.cooldownUntil = performance.now() + SN_CFG.cooldownAfterNav;
snRender();
if (wasOurNavigation && SN_CFG.scrollToTopOnNav && snEnabled()) window.scrollTo(0, 0);
}
function initSmartNavigation() {
if (SN.installed) return;
SN.installed = true;
SN.wrap = document.createElement('div');
SN.wrap.id = 'nh-scroll-progress-wrap';
SN.bar = document.createElement('div');
SN.bar.id = 'nh-scroll-progress-bar';
SN.wrap.appendChild(SN.bar);
document.body.appendChild(SN.wrap);
window.addEventListener('wheel', (e) => {
if (!snEnabled()) {
if (SN.progress > 0) snReset();
return;
}
if (SN.triggered || performance.now() < SN.cooldownUntil) return;
if (e.target && e.target.closest && e.target.closest('.queue-panel, .nf-modal-overlay, .saved-search-extension')) return;
if (e.deltaY > 0 && snAtBottom()) {
snAccumulate(snWheelPixels(e));
} else if (e.deltaY < 0) {
snReset();
}
}, { passive: true });
// ---------- Touch (mobile): swipe up at the bottom of the page ----------
const skipTouchTarget = (t) => t && t.closest && t.closest('.queue-panel, .nf-modal-overlay, .saved-search-extension');
window.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) { SN.touching = false; return; }
SN.touching = true;
SN.touchX = e.touches[0].clientX;
SN.touchY = e.touches[0].clientY;
}, { passive: true });
window.addEventListener('touchmove', (e) => {
if (!SN.touching || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = SN.touchX - t.clientX;
const dy = SN.touchY - t.clientY;
SN.touchX = t.clientX;
SN.touchY = t.clientY;
if (!snEnabled()) {
if (SN.progress > 0) snReset();
return;
}
if (SN.triggered || performance.now() < SN.cooldownUntil) return;
if (skipTouchTarget(e.target)) return;
if (Math.abs(dx) > Math.abs(dy)) return;
const perPx = snThreshold() / numSetting('smartNavSwipe');
if (dy > 0 && snAtBottom(SN_CFG.touchBottomTolerance)) {
snAccumulate(dy * perPx);
} else if (dy < 0 && SN.progress > 0) {
SN.progress = Math.max(0, SN.progress + dy * perPx * 3);
snRender();
}
}, { passive: true });
const endTouch = () => {
SN.touching = false;
SN.lastWheel = performance.now();
};
window.addEventListener('touchend', endTouch, { passive: true });
window.addEventListener('touchcancel', endTouch, { passive: true });
window.addEventListener('scroll', () => {
if (!SN.triggered && SN.progress > 0 && !snAtBottom(SN.touching ? SN_CFG.touchBottomTolerance : undefined)) snReset();
}, { passive: true });
window.addEventListener('pageshow', () => {
SN.triggered = false;
snReset();
});
}
// ==========================================================================
// 9. GALLERY PAGE FEATURES
// ==========================================================================
const CB = { btn: null, badge: null, icon: null, list: null, post: null, listObs: null, atComments: false, shown: null, count: -1, gid: null, apiCount: null, scrollRaf: 0, scrollBound: false };
const cbTotals = new Map();
const cbPending = new Set();
const cbFailUntil = new Map();
function cbFetchTotal(gid) {
if (cbTotals.has(gid) || cbPending.has(gid)) return;
if ((cbFailUntil.get(gid) || 0) > Date.now()) return;
cbPending.add(gid);
fetchRetry(`/api/v2/galleries/${gid}/comments/count`, { credentials: 'same-origin', headers: Object.assign({ accept: 'application/json' }, authHeaders()) }, 0, { wait: false })
.then(r => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then(t => {
const n = parseInt(t, 10);
if (Number.isFinite(n) && n >= 0) { cbTotals.set(gid, n); cbUpdateCount(); }
})
.catch(() => { cbFailUntil.set(gid, Date.now() + 60000); /* keep the DOM count */ })
.finally(() => cbPending.delete(gid));
}
function cbFormat(n) {
return n >= 10000 ? Math.round(n / 1000) + 'k' : String(n);
}
function cbUpdateIcon() {
CB.scrollRaf = 0;
if (!CB.post || !CB.post.isConnected) return;
const at = CB.post.getBoundingClientRect().top < window.innerHeight * 0.6;
if (at !== CB.atComments) { CB.atComments = at; cbSetIcon(); }
}
function cbOnScroll() {
if (!CB.scrollRaf) CB.scrollRaf = requestAnimationFrame(cbUpdateIcon);
}
function cbSetIcon() {
if (CB.icon) CB.icon.className = CB.atComments ? 'fa fa-arrow-up btn-icon' : 'fa fa-comments btn-icon';
}
function cbUpdateCount() {
if (!CB.badge) return;
const list = document.querySelector(SEL.commentList);
const dom = list ? list.querySelectorAll(SEL.comment).length : 0;
const api = CB.gid && cbTotals.has(CB.gid) ? cbTotals.get(CB.gid) : 0;
const count = Math.max(dom, api);
if (count === CB.count) return;
CB.count = count;
CB.badge.style.display = count > 0 ? 'flex' : 'none';
CB.badge.textContent = cbFormat(count);
}
function cbCreate() {
const btn = document.createElement('div');
btn.className = 'comments-scroll-btn';
btn.innerHTML = `
<i class="fa fa-comments btn-icon"></i>
<div class="comments-count" style="display: none;">0</div>
`;
btn.onclick = (e) => {
e.preventDefault();
const freshPost = document.querySelector(SEL.commentPost);
if (!CB.atComments && freshPost) {
freshPost.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
document.body.appendChild(btn);
CB.btn = btn;
CB.badge = btn.querySelector('.comments-count');
CB.icon = btn.querySelector('.btn-icon');
CB.count = -1;
CB.shown = null;
}
function syncCommentsButton() {
const onGallery = /^\/g\/\d+/.test(window.location.pathname);
const post = onGallery ? document.querySelector(SEL.commentPost) : null;
const list = onGallery ? document.querySelector(SEL.commentList) : null;
const visible = !!(post && list);
if (!visible) {
if (CB.btn && CB.shown !== false) { CB.btn.style.display = 'none'; CB.shown = false; }
return;
}
if (!CB.btn || !CB.btn.isConnected) cbCreate();
if (CB.shown !== true) { CB.btn.style.display = ''; CB.shown = true; }
if (CB.list !== list) {
if (CB.listObs) CB.listObs.disconnect();
CB.list = list;
CB.listObs = new MutationObserver(cbUpdateCount);
CB.listObs.observe(list, { childList: true, subtree: true });
}
if (!CB.scrollBound) {
CB.scrollBound = true;
window.addEventListener('scroll', cbOnScroll, { passive: true });
window.addEventListener('resize', cbOnScroll, { passive: true });
}
if (CB.post !== post) {
CB.post = post;
CB.atComments = false;
cbSetIcon();
cbUpdateIcon();
}
const gid = window.location.pathname.match(/^\/g\/(\d+)/)[1];
if (CB.gid !== gid) { CB.gid = gid; CB.count = -1; }
cbFetchTotal(gid);
cbUpdateCount();
}
function initGalleryPageFeatures() {
const _gm = window.location.pathname.match(/\/g\/(\d+)/);
document.querySelectorAll('.btn-queue-add').forEach(btn => {
if (!_gm || btn.dataset.gid !== _gm[1] || !btn.isConnected) btn.remove();
});
const match = window.location.pathname.match(/\/g\/(\d+)/);
if (!match) return;
const btnContainer = document.querySelector(SEL.infoButtons);
const searchInput = document.querySelector(`${SEL.searchForm} ${SEL.searchInput}`);
if (!btnContainer) return;
// 1. Queue Button
if (settings.enableQueue && !document.querySelector('.btn-queue-add')) {
const qBtn = document.createElement('button');
qBtn.className = 'btn btn-secondary btn-queue-add';
qBtn.innerHTML = '<i class="fa fa-clock"></i> Queue';
const galleryId = window.location.href.match(/\/g\/(\d+)/)?.[1];
if (galleryId) {
if (isQueued(galleryId)) {
qBtn.innerHTML = '<i class="far fa-clock"></i> Remove';
qBtn.classList.add('in-queue');
}
qBtn.onclick = () => {
const title = document.querySelector(SEL.galleryTitle).textContent;
const coverImg = document.querySelector(SEL.bigCover);
const coverUrl = coverImg ? (coverImg.dataset.src || coverImg.src) : '';
toggleQueueItem(galleryId, title, coverUrl, window.location.href);
};
qBtn.dataset.gid = galleryId;
btnContainer.appendChild(qBtn);
}
}
// 2. Tag Selector
if (!searchInput || document.querySelector('.btn-tag-selector') || !settings.enableTagSelect) return;
if (!document.querySelector(SEL.tags)) return;
let isSelectionMode = false;
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-secondary btn-tag-selector';
toggleBtn.innerHTML = '<i class="fa fa-tags"></i> Tag Select';
toggleBtn.type = 'button';
const queueBtn = btnContainer.querySelector('.btn-queue-add');
if (queueBtn) {
btnContainer.insertBefore(toggleBtn, queueBtn);
} else {
btnContainer.appendChild(toggleBtn);
}
const tagsContainer = document.querySelector(SEL.tags);
tagsContainer.addEventListener('click', (e) => {
if (!isSelectionMode) return;
const tagLink = e.target.closest(SEL.tagLink);
if (tagLink) {
e.preventDefault(); e.stopPropagation();
if (!tagLink.href.includes('q=pages')) {
tagLink.classList.toggle('tag-selected');
updateSearchBar();
}
}
}, true);
toggleBtn.onclick = () => {
isSelectionMode = !isSelectionMode;
if (isSelectionMode) {
toggleBtn.classList.add('is-active');
tagsContainer.classList.add('tags-selecting-mode');
document.body.classList.add('tag-select-active');
toggleBtn.innerHTML = '<i class="fa fa-check"></i> Done';
window.scrollTo({ top: 0 });
} else {
toggleBtn.classList.remove('is-active');
tagsContainer.classList.remove('tags-selecting-mode');
document.body.classList.remove('tag-select-active');
toggleBtn.innerHTML = '<i class="fa fa-tags"></i> Tag Select';
}
};
function updateSearchBar() {
const selectedTags = tagsContainer.querySelectorAll(SEL.tagLinkSelected);
const queryTerms = Array.from(selectedTags).map(tag => {
const nameSpan = tag.querySelector(SEL.tagName);
let tagName = nameSpan ? nameSpan.textContent.trim() : '';
const href = tag.getAttribute('href');
if (href.includes('/tag/')) return `tag:"${tagName}"`;
if (href.includes('/artist/')) return `artist:"${tagName}"`;
if (href.includes('/group/')) return `group:"${tagName}"`;
if (href.includes('/parody/')) return `parody:"${tagName}"`;
if (href.includes('/character/')) return `character:"${tagName}"`;
if (href.includes('/language/')) return `language:"${tagName}"`;
if (href.includes('/category/')) return `category:"${tagName}"`;
return `"${tagName}"`;
});
searchInput.value = queryTerms.join(' ');
searchInput.dispatchEvent(new Event('input'));
}
syncCommentsButton();
}
function syncFavoriteButtonState() {
const btn = document.querySelector(SEL.favoriteButton);
const id = window.location.pathname.match(/^\/g\/(\d+)/)?.[1];
if (!btn || !id) return;
const label = (btn.querySelector(SEL.favoriteLabel) || btn).textContent.trim().toLowerCase();
if (label.includes('unfavorite')) addFav(id); else removeFav(id);
}
function initFavoriteButtonWatcher() {
const btn = document.querySelector(SEL.favoriteButton);
const id = window.location.pathname.match(/^\/g\/(\d+)/)?.[1];
if (!btn || !id) return;
if (btn.dataset.nfFavFor !== id) {
btn.dataset.nfFavFor = id;
syncFavoriteButtonState();
}
if (!btn.dataset.nfFavWatch) {
btn.dataset.nfFavWatch = '1';
new MutationObserver(syncFavoriteButtonState).observe(btn, { childList: true, characterData: true, subtree: true });
}
}
// ==========================================================================
// 10. SAVED SEARCHES
// ==========================================================================
let searchStorageHandler = null;
function initSearchFlow() {
if (!settings.enableSavedSearch) return;
const form = document.querySelector(SEL.searchForm);
const nav = form ? form.closest(SEL.nav) : document.querySelector(SEL.nav);
if (!form || !nav || form.dataset.savedInit) return;
form.dataset.savedInit = '1';
const input = form.querySelector(SEL.searchInput);
if (input) input.style.paddingRight = '40px';
const SEP_MARKER = '---SEP---';
let searchData = readJSON('nhentai_search_flow', { saved: [] });
if (!searchData || !Array.isArray(searchData.saved)) searchData = { saved: [] };
let isDeleteMode = false;
let isReorderMode = false;
let pendingDeletes = new Set();
const saveSearch = () => localStorage.setItem('nhentai_search_flow', JSON.stringify(searchData));
const toggleSavedItem = (query) => {
if (!query) return;
const idx = searchData.saved.indexOf(query);
if (idx > -1) searchData.saved.splice(idx, 1);
else searchData.saved.push(query);
saveSearch();
renderBar();
};
const addSeparator = () => {
searchData.saved.push(SEP_MARKER);
saveSearch();
renderBar();
const list = barContainer.querySelector('.sse-list');
if (list) list.scrollTop = list.scrollHeight;
};
form.querySelectorAll('.search-saved-trigger').forEach(el => el.remove());
document.querySelectorAll('.saved-search-extension').forEach(el => el.remove());
const trigger = document.createElement('div');
trigger.className = 'search-saved-trigger';
trigger.innerHTML = '<i class="far fa-clipboard"></i>';
trigger.title = "Saved Searches";
form.appendChild(trigger);
const barContainer = document.createElement('div');
barContainer.className = 'saved-search-extension';
nav.parentNode.insertBefore(barContainer, nav.nextSibling);
const renderBar = () => {
let prevScroll = 0;
const existingList = barContainer.querySelector('.sse-list');
if (existingList) prevScroll = existingList.scrollTop;
const currentQ = input ? input.value.trim() : '';
const baseQ = currentQ.replace(/(^|\s)-/g, '$1').trim();
const isCurrentSaved = baseQ && searchData.saved.includes(baseQ);
let listClass = 'sse-list';
if (isDeleteMode) listClass += ' delete-mode';
if (isReorderMode) listClass += ' reorder-mode';
let actionButtons = '';
let saveCurrentBtn = '';
if (isDeleteMode) {
actionButtons = `
${pendingDeletes.size > 0 ? `
<button class="btn-sse-save" id="btn-confirm-del" style="margin-right: 5px;">Save Changes</button>
` : ''}
<button class="btn-sse-edit" id="btn-cancel-del">Cancel</button>
`;
} else if (isReorderMode) {
actionButtons = `
<button class="btn-sse-save" id="btn-finish-reorder" style="margin-right: 5px;">
Save
</button>
<button class="btn-sse-edit" id="btn-add-sep">Add Separator</button>
`;
} else {
if (searchData.saved.length > 0) {
actionButtons = `
<button class="btn-sse-edit" id="btn-toggle-reorder" style="margin-right: 5px;" title="Organize items">
<i class="fa fa-sort"></i> Reorder
</button>
<button class="btn-sse-edit" id="btn-toggle-edit" title="Delete items">
<i class="fas fa-trash-alt"></i> Delete
</button>
`;
}
if (!isCurrentSaved && currentQ) {
saveCurrentBtn = `<button class="btn-sse-save" id="btn-save-curr-bar" style="margin-right:5px"><i class="fa fa-plus"></i> Save</button>`;
}
}
let html = `
<div class="sse-inner">
<div class="sse-header">
<div style="display:flex; gap:5px; align-items:center;">
${saveCurrentBtn}
</div>
<div class="sse-actions">
${actionButtons}
</div>
</div>
<div class="${listClass}">
`;
if (searchData.saved.length === 0) {
html += `<div class="sse-empty">No saved searches yet. Build a search and click Save to add it here.</div>`;
} else {
searchData.saved.forEach((q, index) => {
if (q === SEP_MARKER) {
const isMarked = pendingDeletes.has(index.toString());
const deleteStyle = isMarked ? 'to-delete' : '';
const draggableAttr = isReorderMode ? 'draggable="true"' : '';
html += `<div class="ss-separator sse-item ${deleteStyle}" ${draggableAttr} data-index="${index}" data-type="sep"></div>`;
}
else {
const isCurrent = q === currentQ ? 'is-current' : '';
const isMarked = pendingDeletes.has(q);
const deleteStyle = isMarked ? 'to-delete' : '';
const safeQ = q.replace(/"/g, '"');
const displayLabel = (q.match(/(?:[^\s"]+|"[^"]*")+/g) || [])
.map(term => term.replace(/\b[a-z-]+:/g, '').replace(/"/g, '').trim())
.filter(Boolean)
.join(' / ');
const categoryMatch = q.match(/^-?(tag|artist|parody|character|group|language|category):/i);
const pillCategory = categoryMatch ? categoryMatch[1].toLowerCase() : 'tag';
let tooltip = safeQ;
const draggableAttr = isReorderMode ? 'draggable="true"' : '';
const escapedTerm = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const positiveRegex = new RegExp(`(^|\\s)${escapedTerm}(?=\\s|$)`);
const negativeRegex = new RegExp(`(^|\\s)-${escapedTerm}(?=\\s|$)`);
let iconClass = 'fa-plus';
let btnStateClass = '';
if (negativeRegex.test(currentQ)) {
iconClass = 'fa-times';
btnStateClass = 'ss-add-negative';
} else if (positiveRegex.test(currentQ)) {
iconClass = 'fa-minus';
btnStateClass = 'ss-add-positive';
}
html += `
<div class="ss-pill sse-item ${isCurrent} ${deleteStyle}" ${draggableAttr} data-index="${index}" data-type="pill" data-category="${pillCategory}">
<div class="ss-part ss-text" data-full-query="${safeQ}" title="${tooltip}">${displayLabel}</div>
<div class="ss-part ss-add ${btnStateClass}" data-q="${safeQ}"><i class="fa ${iconClass}" style="font-size: 10px;"></i></div>
</div>
`;
}
});
}
html += `</div></div>`;
barContainer.innerHTML = html;
const newList = barContainer.querySelector('.sse-list');
if (newList) newList.scrollTop = prevScroll;
if (isReorderMode) {
const listElement = barContainer.querySelector('.sse-list');
if (listElement.dataset.dndInit) return;
listElement.dataset.dndInit = '1';
let draggedIndex = null;
let currentDropTarget = null;
// 1.1 DESKTOP (Event Delegation)
listElement.addEventListener('dragstart', (e) => {
const item = e.target.closest('.sse-item');
if (!item) return;
draggedIndex = parseInt(item.dataset.index);
item.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
});
listElement.addEventListener('dragover', (e) => {
e.preventDefault();
const item = e.target.closest('.sse-item');
if (item && item !== currentDropTarget) {
if (currentDropTarget) currentDropTarget.classList.remove('drag-over');
const targetIndex = parseInt(item.dataset.index);
if (targetIndex !== draggedIndex) {
item.classList.add('drag-over');
currentDropTarget = item;
} else {
currentDropTarget = null;
}
}
});
listElement.addEventListener('dragend', (e) => {
const item = e.target.closest('.sse-item');
if (item) item.classList.remove('is-dragging');
if (currentDropTarget) {
currentDropTarget.classList.remove('drag-over');
currentDropTarget = null;
}
});
listElement.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (!currentDropTarget || draggedIndex === null) return;
const targetIndex = parseInt(currentDropTarget.dataset.index);
if (draggedIndex !== targetIndex) {
const itemToMove = searchData.saved.splice(draggedIndex, 1)[0];
searchData.saved.splice(targetIndex, 0, itemToMove);
saveSearch();
renderBar();
}
draggedIndex = null;
});
// 1.2 MOBILE (Event Delegation + rAF Throttling)
let touchSourceItem = null;
let longPressTimer = null;
let isDragActive = false;
let autoScrollRafId = null;
let scrollDirection = 0;
let lastTouchY = 0;
let rafId = null;
const scrollLoop = () => {
if (!isDragActive || !listElement || scrollDirection === 0) {
autoScrollRafId = null;
return;
}
const speed = 8; // 60fps, 8px = ~480px per second.
listElement.scrollTop += (speed * scrollDirection);
autoScrollRafId = requestAnimationFrame(scrollLoop);
};
const handleAutoScroll = () => {
if (!isDragActive || !listElement) return;
const rect = listElement.getBoundingClientRect();
const threshold = 50;
if (lastTouchY < rect.top + threshold) {
scrollDirection = -1;
} else if (lastTouchY > rect.bottom - threshold) {
scrollDirection = 1;
} else {
scrollDirection = 0;
}
if (scrollDirection !== 0 && !autoScrollRafId) {
autoScrollRafId = requestAnimationFrame(scrollLoop);
}
};
const stopAutoScroll = () => {
scrollDirection = 0;
if (autoScrollRafId) {
cancelAnimationFrame(autoScrollRafId);
autoScrollRafId = null;
}
};
listElement.addEventListener('touchstart', (e) => {
const item = e.target.closest('.sse-item');
if (!item) return;
touchSourceItem = item;
draggedIndex = parseInt(item.dataset.index);
item.classList.add('is-pressing');
longPressTimer = setTimeout(() => {
isDragActive = true;
item.classList.remove('is-pressing');
item.classList.add('is-dragging');
if (navigator.vibrate) navigator.vibrate(50);
document.body.style.overflow = 'hidden';
}, 500);
}, { passive: true });
listElement.addEventListener('touchmove', (e) => {
if (!isDragActive) {
clearTimeout(longPressTimer);
if (touchSourceItem) touchSourceItem.classList.remove('is-pressing');
return;
}
e.preventDefault();
const touch = e.touches[0];
lastTouchY = touch.clientY;
handleAutoScroll();
if (!rafId) {
rafId = requestAnimationFrame(() => {
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
rafId = null;
if (!elementBelow) return;
const targetItem = elementBelow.closest('.sse-item');
if (targetItem !== currentDropTarget) {
if (currentDropTarget) currentDropTarget.classList.remove('drag-over');
if (targetItem && targetItem !== touchSourceItem) {
targetItem.classList.add('drag-over');
currentDropTarget = targetItem;
} else {
currentDropTarget = null;
}
}
});
}
}, { passive: false });
const cleanupMobileDrag = () => {
clearTimeout(longPressTimer);
stopAutoScroll();
document.body.style.overflow = '';
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (touchSourceItem) touchSourceItem.classList.remove('is-pressing', 'is-dragging');
if (currentDropTarget) {
currentDropTarget.classList.remove('drag-over');
currentDropTarget = null;
}
isDragActive = false;
touchSourceItem = null;
};
listElement.addEventListener('touchend', (e) => {
if (isDragActive && currentDropTarget && draggedIndex !== null) {
const targetIndex = parseInt(currentDropTarget.dataset.index);
if (targetIndex !== draggedIndex) {
const itemToMove = searchData.saved.splice(draggedIndex, 1)[0];
searchData.saved.splice(targetIndex, 0, itemToMove);
saveSearch();
renderBar();
}
}
cleanupMobileDrag();
draggedIndex = null;
});
listElement.addEventListener('touchcancel', cleanupMobileDrag);
}
// 2. Control Buttons Handlers
const btnDeleteMode = barContainer.querySelector('#btn-toggle-edit');
if (btnDeleteMode) {
btnDeleteMode.onclick = (e) => {
e.stopPropagation();
isDeleteMode = true;
isReorderMode = false;
pendingDeletes.clear();
renderBar();
};
}
const btnReorderMode = barContainer.querySelector('#btn-toggle-reorder');
if (btnReorderMode) {
btnReorderMode.onclick = (e) => {
e.stopPropagation();
isReorderMode = true;
isDeleteMode = false;
renderBar();
};
}
const btnFinishReorder = barContainer.querySelector('#btn-finish-reorder');
if (btnFinishReorder) {
btnFinishReorder.onclick = (e) => {
e.stopPropagation();
isReorderMode = false;
renderBar();
};
}
const btnConfirm = barContainer.querySelector('#btn-confirm-del');
if (btnConfirm) {
btnConfirm.onclick = (e) => {
e.stopPropagation();
if (pendingDeletes.size > 0) {
const newSaved = [];
searchData.saved.forEach((item, idx) => {
if (item === SEP_MARKER) {
if (!pendingDeletes.has(idx.toString())) newSaved.push(item);
} else {
if (!pendingDeletes.has(item)) newSaved.push(item);
}
});
searchData.saved = newSaved;
saveSearch();
}
isDeleteMode = false;
pendingDeletes.clear();
renderBar();
};
}
const btnCancel = barContainer.querySelector('#btn-cancel-del');
if (btnCancel) {
btnCancel.onclick = (e) => {
e.stopPropagation();
isDeleteMode = false;
pendingDeletes.clear();
renderBar();
};
}
const btnSave = barContainer.querySelector('#btn-save-curr-bar');
if (btnSave) {
btnSave.onclick = (e) => {
e.stopPropagation();
toggleSavedItem(baseQ);
};
}
const btnAddSep = barContainer.querySelector('#btn-add-sep');
if (btnAddSep) {
btnAddSep.onclick = (e) => {
e.stopPropagation();
addSeparator();
};
}
// 3. Event Delegation
barContainer.onclick = (e) => {
const addBtn = e.target.closest('.ss-add');
if (addBtn) {
e.stopPropagation();
if (isDeleteMode || isReorderMode) return;
const queryToAdd = addBtn.dataset.q.replace(/"/g, '"');
let currentVal = input.value.trim();
const escapedTerm = queryToAdd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const positiveRegex = new RegExp(`(^|\\s)${escapedTerm}(?=\\s|$)`);
const negativeRegex = new RegExp(`(^|\\s)-${escapedTerm}(?=\\s|$)`);
if (negativeRegex.test(currentVal)) {
currentVal = currentVal.replace(negativeRegex, '').replace(/\s{2,}/g, ' ').trim();
} else if (positiveRegex.test(currentVal)) {
currentVal = currentVal.replace(positiveRegex, `$1-${queryToAdd}`).trim();
} else {
currentVal = currentVal ? currentVal + ' ' + queryToAdd : queryToAdd;
}
input.value = currentVal;
input.focus();
renderBar();
input.dispatchEvent(new Event('input'));
return;
}
const itemEl = e.target.closest('.sse-item');
if (itemEl && !isReorderMode) {
e.stopPropagation();
const type = itemEl.dataset.type;
const index = itemEl.dataset.index;
if (isDeleteMode) {
const key = type === 'sep' ? index.toString() : itemEl.querySelector('.ss-text').dataset.fullQuery;
if (pendingDeletes.has(key)) pendingDeletes.delete(key);
else pendingDeletes.add(key);
renderBar();
} else if (type === 'pill') {
const fullQuery = itemEl.querySelector('.ss-text').dataset.fullQuery;
if(fullQuery) window.location.href = `/search/?q=${encodeURIComponent(fullQuery)}`;
}
}
};
};
if (searchStorageHandler) window.removeEventListener('storage', searchStorageHandler);
searchStorageHandler = (e) => {
if (e.key === 'nhentai_search_flow' && e.newValue) {
searchData = JSON.parse(e.newValue);
renderBar();
}
};
window.addEventListener('storage', searchStorageHandler);
trigger.onclick = (e) => {
e.stopPropagation();
const isVisible = barContainer.classList.contains('is-visible');
if (!isVisible) {
isDeleteMode = false;
isReorderMode = false;
pendingDeletes.clear();
renderBar();
barContainer.classList.add('is-visible');
trigger.classList.add('is-active');
} else {
barContainer.classList.remove('is-visible');
trigger.classList.remove('is-active');
}
};
form.addEventListener('click', (e) => {
if (!e.target.closest('button.ac-clear')) return;
setTimeout(() => {
clearTimeout(renderTimeout);
renderBar();
}, 0);
}, true);
let renderTimeout;
if (input) {
input.addEventListener('input', () => {
if (!barContainer.classList.contains('is-visible')) return;
clearTimeout(renderTimeout);
renderTimeout = setTimeout(() => {
renderBar();
}, 300);
});
}
}
// ==========================================================================
// 11. CUSTOM CONTEXT MENU
// ==========================================================================
function initContextMenu() {
if (document.body.dataset.contextMenuInit) return;
document.body.dataset.contextMenuInit = '1';
const menu = document.createElement('div');
menu.className = 'nh-context-menu';
document.body.appendChild(menu);
let currentTargetId = null;
let currentTargetUrl = null;
let currentTargetTitle = '';
document.addEventListener('contextmenu', (e) => {
if (!settings.enableContextMenu) return;
const gallery = e.target.closest(SEL.gallery);
if (!gallery) {
closeMenu();
return;
}
const link = gallery.querySelector(SEL.cover);
if (!link) return;
e.preventDefault();
currentTargetId = gallery.dataset.gid;
currentTargetUrl = link.href;
currentTargetTitle = gallery.querySelector(SEL.caption)?.textContent.trim()
|| metaCache.get(currentTargetId)?.title
|| '';
renderMenuOptions(menu);
menu.classList.add('is-visible');
positionMenu(e.clientX, e.clientY);
});
window.addEventListener('click', (e) => {
if (menu.contains(e.target)) return;
closeMenu();
}, true);
['scroll', 'resize'].forEach(evt => window.addEventListener(evt, closeMenu));
function closeMenu() {
menu.classList.remove('is-visible');
}
function positionMenu(x, y) {
const margin = 8;
const rect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin))}px`;
}
function openSubmenu(group) {
menu.querySelectorAll('.nh-cm-group').forEach(item => {
const isOpen = item === group;
item.classList.toggle('is-open', isOpen);
item.querySelector('.nh-cm-parent').setAttribute('aria-expanded', String(isOpen));
});
const submenu = group.querySelector('.nh-cm-submenu');
const row = group.getBoundingClientRect();
const rect = submenu.getBoundingClientRect();
const margin = 8;
let left = row.right;
if (left + rect.width > window.innerWidth - margin) {
left = row.left - rect.width;
}
submenu.style.left = `${Math.max(margin, Math.min(left, window.innerWidth - rect.width - margin))}px`;
submenu.style.top = `${Math.max(margin, Math.min(row.top - 10, window.innerHeight - rect.height - margin))}px`;
}
function renderMenuOptions(menuElement) {
const galleryState = states.get(currentTargetId);
const currentPage = galleryState ? galleryState.curr : 1;
const readUrl = `/g/${currentTargetId}/${currentPage}/`;
const readLabel = currentPage > 1 ? `Read from page ${currentPage}` : `Read now`;
menuElement.innerHTML = `
<div class="nh-cm-item" id="cm-new-tab">
Open in new tab
</div>
<div class="nh-cm-item" id="cm-read-now">
${readLabel}
</div>
<div class="nh-cm-separator"></div>
<div class="nh-cm-group" id="cm-explore">
<button type="button" class="nh-cm-item nh-cm-parent"
aria-expanded="false" aria-controls="cm-explore-submenu">
<span>Explore</span>
<i class="fa fa-angle-right" aria-hidden="true"></i>
</button>
<div class="nh-cm-submenu nh-cm-explore-submenu" id="cm-explore-submenu">
<div class="nh-cm-status">Loading...</div>
</div>
</div>
<div class="nh-cm-group" id="cm-copy">
<button type="button" class="nh-cm-item nh-cm-parent"
aria-expanded="false" aria-controls="cm-copy-submenu">
<span>Copy</span>
<i class="fa fa-angle-right" aria-hidden="true"></i>
</button>
<div class="nh-cm-submenu" id="cm-copy-submenu">
<button type="button" class="nh-cm-item" data-copy="link">Link</button>
<button type="button" class="nh-cm-item" data-copy="title">Title</button>
<button type="button" class="nh-cm-item" data-copy="id">ID: ${currentTargetId}</button>
</div>
</div>
<div class="nh-cm-footer" id="cm-close-hint">
Shift + right-click to open system menu
</div>
`;
menuElement.querySelector('#cm-read-now').onclick = () => {
window.location.href = readUrl;
closeMenu();
};
menuElement.querySelector('#cm-new-tab').onclick = () => {
if (typeof GM_openInTab !== 'undefined') {
GM_openInTab(currentTargetUrl, { active: false, insert: true });
} else {
window.open(currentTargetUrl, '_blank');
}
closeMenu();
};
const copyGroup = menuElement.querySelector('#cm-copy');
const copyButton = copyGroup.querySelector('.nh-cm-parent');
const copyValues = {
link: currentTargetUrl,
title: currentTargetTitle,
id: currentTargetId
};
const closeCopy = () => {
copyGroup.classList.remove('is-open');
copyButton.setAttribute('aria-expanded', 'false');
};
copyGroup.onmouseenter = () => openSubmenu(copyGroup);
copyGroup.onmouseleave = () => {
if (!copyGroup.contains(document.activeElement)) closeCopy();
};
copyButton.onclick = copyButton.onfocus = () => openSubmenu(copyGroup);
copyGroup.onfocusout = (e) => {
if (!copyGroup.contains(e.relatedTarget)) closeCopy();
};
copyGroup.querySelectorAll('[data-copy]').forEach(button => {
const text = copyValues[button.dataset.copy] || '';
button.disabled = !text;
button.onclick = async (e) => {
e.stopPropagation();
if (!text) return;
closeMenu();
try {
await navigator.clipboard.writeText(text);
} catch {
window.prompt('Copy manually:', text);
}
};
});
const exploreGroup = menuElement.querySelector('#cm-explore');
const exploreButton = exploreGroup.querySelector('.nh-cm-parent');
const exploreSubmenu = exploreGroup.querySelector('.nh-cm-submenu');
const exploreId = String(currentTargetId);
let exploreLoading = false;
let exploreLoaded = false;
const exploreMessage = (text) => {
const message = document.createElement('div');
message.className = 'nh-cm-status';
message.textContent = text;
exploreSubmenu.replaceChildren(message);
};
const closeExplore = () => {
exploreGroup.classList.remove('is-open');
exploreButton.setAttribute('aria-expanded', 'false');
};
const showExplore = async () => {
if (!exploreLoading && !exploreLoaded) exploreMessage('Loading...');
openSubmenu(exploreGroup);
if (exploreLoading || exploreLoaded) return;
exploreLoading = true;
try {
const meta = await getMeta(exploreId);
if (!exploreGroup.isConnected) return;
if (!meta || !Array.isArray(meta.tags)) {
throw new Error('Gallery metadata unavailable');
}
const fragment = document.createDocumentFragment();
for (const [type, label] of [['artist', 'Artist'], ['group', 'Group']]) {
const tags = meta.tags.filter(tag =>
tag.type === type && tag.name && tag.slug
);
if (!tags.length) continue;
const heading = document.createElement('div');
heading.className = 'nh-cm-section';
heading.textContent = label;
fragment.appendChild(heading);
for (const tag of tags) {
const link = document.createElement('a');
link.className = 'nh-cm-item nh-cm-explore-link';
link.textContent = tag.name;
link.href = `/${type}/${encodeURIComponent(tag.slug)}/`;
link.onclick = (e) => {
e.stopPropagation();
closeMenu();
};
fragment.appendChild(link);
}
}
if (fragment.childNodes.length) {
exploreSubmenu.replaceChildren(fragment);
} else {
exploreMessage('No artist or group listed.');
}
exploreLoaded = true;
} catch {
if (exploreGroup.isConnected) {
exploreMessage('Unable to load. Try again later.');
}
} finally {
exploreLoading = false;
if (exploreGroup.isConnected
&& menuElement.classList.contains('is-visible')
&& exploreGroup.classList.contains('is-open')) {
openSubmenu(exploreGroup);
}
}
};
exploreGroup.onmouseenter = showExplore;
exploreButton.onclick = exploreButton.onfocus = showExplore;
exploreGroup.onmouseleave = () => {
if (!exploreGroup.contains(document.activeElement)) closeExplore();
};
exploreGroup.onfocusout = (e) => {
if (!exploreGroup.contains(e.relatedTarget)) closeExplore();
};
menuElement.querySelector('#cm-close-hint').onclick = () => {
closeMenu();
};
}
}
// ==========================================================================
// 12. GLOBAL SHORTCUTS
// ==========================================================================
let shortcutsInstalled = false;
function getPrimarySearchInput() {
return document.querySelector(`${SEL.nav} ${SEL.searchForm} ${SEL.searchInput}`) || document.querySelector(`${SEL.searchForm} ${SEL.searchInput}`);
}
function initGlobalShortcuts() {
const input = getPrimarySearchInput();
const form = input && input.closest('form');
if (form && !form.querySelector('.search-slash-hint')) {
const hint = document.createElement('div');
hint.className = 'search-slash-hint';
hint.textContent = 'Type / to search';
form.insertBefore(hint, input.nextSibling);
}
if (shortcutsInstalled) return;
shortcutsInstalled = true;
document.addEventListener('keydown', (e) => {
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
if (e.key === 'Escape') target.blur();
return;
}
if (e.ctrlKey || e.altKey || e.metaKey || e.isComposing) return;
if (e.key === '/') {
const current = getPrimarySearchInput();
if (current) {
e.preventDefault();
current.focus();
current.select();
}
}
});
}
// ==========================================================================
// 13. DATA MANAGEMENT (BACKUP/RESTORE)
// ==========================================================================
function exportData() {
const data = {
settings: readJSON('nhentai_flow_settings', {}),
queue: readJSON('nhentai_queue_v1', []),
savedSearches: readJSON('nhentai_search_flow', { saved: [] })
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `nh-flow-backup-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function importData() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
const isObject = value =>
value !== null &&
typeof value === 'object' &&
!Array.isArray(value);
const has = (object, key) =>
Object.prototype.hasOwnProperty.call(object, key);
function validate(data) {
if (!isObject(data)) {
throw new Error('The file must contain a JSON object.');
}
if (!['settings', 'queue', 'savedSearches'].some(key => has(data, key))) {
throw new Error('No backup categories were found.');
}
if (has(data, 'settings')) {
if (!isObject(data.settings)) {
throw new Error('Invalid settings.');
}
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!has(data.settings, key)) continue;
const value = data.settings[key];
const expected = typeof DEFAULT_SETTINGS[key];
if (
typeof value !== expected ||
(expected === 'number' && !Number.isFinite(value))
) {
throw new Error(`Invalid setting: ${key}`);
}
}
}
if (has(data, 'queue')) {
if (
!Array.isArray(data.queue) ||
!data.queue.every(item =>
isObject(item) &&
['string', 'number'].includes(typeof item.id) &&
/^[1-9]\d*$/.test(String(item.id)) &&
['title', 'coverUrl', 'galleryUrl'].every(
key => typeof item[key] === 'string'
)
)
) {
throw new Error('Invalid reading queue.');
}
}
if (has(data, 'savedSearches')) {
if (
!isObject(data.savedSearches) ||
!Array.isArray(data.savedSearches.saved) ||
!data.savedSearches.saved.every(item => typeof item === 'string')
) {
throw new Error('Invalid saved searches.');
}
}
}
function chooseMode() {
return new Promise(resolve => {
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'nf-modal-overlay active';
overlay.innerHTML = `
<div class="nf-modal" role="dialog" aria-modal="true"
aria-labelledby="nf-import-title" tabindex="-1">
<div class="nf-modal-header">
<h2 id="nf-import-title">Import backup</h2>
</div>
<div class="nf-modal-body">
<p><strong>Merge:</strong> keeps current queue items and adds new ones without duplicate IDs. Imported searches, including separators and duplicates, are appended to the end. Current settings are preserved.</p>
<p><strong>Replace:</strong> overwrites your current reading queue, saved searches, and settings with the data from the backup.</p>
<p><strong>Note:</strong> Merging the same file again will duplicate the imported block of saved searches.</p>
</div>
<div class="nf-modal-footer"
style="justify-content:end">
<div class="nf-footer-actions"
style="flex-wrap:wrap">
<button type="button" class="nf-btn nf-btn-ghost"
data-mode="cancel">Cancel</button>
<button type="button" class="nf-btn"
data-mode="replace">Replace</button>
<button type="button" class="nf-btn nf-btn-primary"
data-mode="merge">Merge</button>
</div>
</div>
</div>
`;
function finish(mode) {
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (previousFocus && previousFocus.isConnected) {
previousFocus.focus({ preventScroll: true });
}
resolve(mode);
}
function onKey(event) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
finish('cancel');
} else if (event.key === 'Tab') {
const buttons = [...overlay.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
const next = event.shiftKey
? (index <= 0 ? buttons.length - 1 : index - 1)
: (index + 1) % buttons.length;
event.preventDefault();
event.stopImmediatePropagation();
buttons[next].focus();
}
}
overlay.querySelectorAll('[data-mode]').forEach(button => {
button.onclick = () => finish(button.dataset.mode);
});
overlay.addEventListener('mousedown', event => {
if (event.target === overlay) finish('cancel');
});
document.addEventListener('keydown', onKey, true);
document.body.appendChild(overlay);
overlay.querySelector('[data-mode="merge"]').focus();
});
}
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
let data;
try {
data = JSON.parse(await file.text());
validate(data);
} catch (error) {
alert(`Failed to read backup: ${error.message}`);
return;
}
const mode = await chooseMode();
if (mode === 'cancel') return;
const merge = mode === 'merge';
const updates = new Map();
if (has(data, 'settings')) {
const current = readJSON('nhentai_flow_settings', {});
const imported = data.settings;
const result = {};
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (merge && isObject(current) && has(current, key)) {
result[key] = current[key];
} else if (has(imported, key)) {
result[key] = imported[key];
}
}
updates.set('nhentai_flow_settings', result);
}
if (has(data, 'queue')) {
let queue = data.queue;
if (merge) {
queue = [...readingQueue];
const ids = new Set(queue.map(item => String(item.id)));
for (const item of data.queue) {
const id = String(item.id);
if (!ids.has(id)) {
queue.push(item);
ids.add(id);
}
}
}
updates.set('nhentai_queue_v1', queue);
}
if (has(data, 'savedSearches')) {
const current = readJSON('nhentai_search_flow', { saved: [] });
const saved = isObject(current) && Array.isArray(current.saved)
? current.saved
: [];
updates.set('nhentai_search_flow', {
saved: merge
? [...saved, ...data.savedSearches.saved]
: [...data.savedSearches.saved]
});
}
const previous = new Map();
const written = [];
try {
for (const key of updates.keys()) {
previous.set(key, localStorage.getItem(key));
}
for (const [key, value] of updates) {
localStorage.setItem(key, JSON.stringify(value));
written.push(key);
}
} catch (error) {
let restored = true;
for (const key of written.reverse()) {
try {
const oldValue = previous.get(key);
if (oldValue === null) localStorage.removeItem(key);
else localStorage.setItem(key, oldValue);
} catch {
restored = false;
}
}
console.error('[NHentai Flow] Import failed:', error);
alert(
restored
? 'Failed to save the backup. Your previous data has been preserved.'
: 'Import failed, and some previous data could not be restored.'
);
return;
}
if (updates.has('nhentai_queue_v1')) {
clearTimeout(saveQueueTimeout);
readingQueue = updates.get('nhentai_queue_v1');
}
alert('Backup imported successfully! The page will reload.');
window.location.reload();
};
input.click();
}
// ==========================================================================
// 14. SETTINGS MENU UI
// ==========================================================================
function initSettingsMenu() {
if (document.getElementById('nf-settings-btn')) return;
const nav = document.querySelector('.menu.right');
if (!nav) return;
const li = document.createElement('li');
li.id = 'nf-settings-btn';
li.innerHTML = `<a href="javascript:void(0)"><i class="fa fa-cog"></i> Flow</a>`;
li.onclick = (e) => {
e.preventDefault();
openSettingsModal();
};
nav.prepend(li);
}
const SETTINGS_UI = [
{ title: 'Navigation', items: [
['smartNav', 'Scroll to next page', 'Keep scrolling (or swipe up) at the bottom of a page to go to the next one.'],
{ range: 'smartNavScroll', depends: 'smartNav', name: 'Scroll distance', desc: 'How much to keep scrolling with the mouse wheel before the page changes.', fmt: v => `≈ ${Math.round(v / 100)} wheel clicks` },
{ range: 'smartNavSwipe', depends: 'smartNav', name: 'Swipe distance', desc: 'How far to drag up on a touch screen before the page changes.', fmt: v => `${v} px` },
['previewNav', 'Card page flipping', 'Flip through a gallery’s pages directly on its card.'],
['paginationRight', 'Pagination on the right', 'Put the side pagination on the right instead of the left.']
]},
{ title: 'Cards', items: [
['highlightVisited', 'Highlight visited', 'Mark galleries you have already opened.'],
['showTagOverlay', 'Tag overlay', 'Show a gallery’s tags when hovering its card.'],
['enableContextMenu', 'Context menu', 'Right-click a card for quick actions.']
]},
{ title: 'Tools', items: [
['enableQueue', 'Reading queue', 'Add galleries to a queue and read them in order.'],
['enableTagSelect', 'Tag Select', 'Build a search by clicking tags on a gallery page.'],
['enableSavedSearch', 'Saved searches', 'Keep favorite searches one click away under the search bar.']
]}
];
function openSettingsModal() {
let overlay = document.getElementById('nf-settings-modal');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'nf-settings-modal';
overlay.className = 'nf-modal-overlay';
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const row = (key, name, desc) => `
<label class="nf-setting-row" for="nf-set-${key}">
<span class="nf-setting-text">
<span class="nf-setting-name">${esc(name)}</span>
${desc ? `<span class="nf-setting-desc">${esc(desc)}</span>` : ''}
</span>
<input type="checkbox" id="nf-set-${key}" class="nf-setting-checkbox" data-key="${key}">
</label>`;
const rangeRow = (it) => {
const [min, max, step] = NUM_LIMITS[it.range];
return `
<div class="nf-range-row" data-depends="${esc(it.depends || '')}">
<div class="nf-range-head">
<span class="nf-setting-name">${esc(it.name)}</span>
<span class="nf-range-right">
<span class="nf-range-value" data-for="${it.range}"></span>
<button type="button" class="nf-range-reset" data-for="${it.range}" title="Reset to default" aria-label="Reset ${esc(it.name)} to default"><i class="fa fa-undo"></i></button>
</span>
</div>
<span class="nf-setting-desc">${esc(it.desc)}</span>
<input type="range" id="nf-set-${it.range}" class="nf-setting-range" data-key="${it.range}" min="${min}" max="${max}" step="${step}">
</div>`;
};
const itemKey = (it) => (Array.isArray(it) ? it[0] : it.range);
const known = new Set();
const rangeFmt = {};
let settingsHtml = '';
SETTINGS_UI.forEach(group => {
const rows = group.items.filter(it => itemKey(it) in DEFAULT_SETTINGS);
if (!rows.length) return;
settingsHtml += `<div class="nf-section-title">${esc(group.title)}</div>`;
rows.forEach(it => {
known.add(itemKey(it));
if (Array.isArray(it)) {
settingsHtml += row(it[0], it[1], it[2]);
} else {
rangeFmt[it.range] = it.fmt;
settingsHtml += rangeRow(it);
}
});
});
const others = Object.keys(DEFAULT_SETTINGS).filter(k => !known.has(k) && typeof DEFAULT_SETTINGS[k] === 'boolean');
if (others.length) {
settingsHtml += `<div class="nf-section-title">Other</div>`;
others.forEach(key => {
const name = key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
settingsHtml += row(key, name, '');
});
}
const dataHtml = `
<div class="nf-section-title">Data</div>
<div class="nf-data-grid">
<button type="button" id="nf-sync-btn" class="nf-btn"><i class="fa fa-sync-alt"></i><span class="nf-btn-label">Sync Favs</span></button>
<button type="button" id="nf-export-btn" class="nf-btn"><i class="fa fa-download"></i><span class="nf-btn-label">Export</span></button>
<button type="button" id="nf-import-btn" class="nf-btn"><i class="fa fa-upload"></i><span class="nf-btn-label">Import</span></button>
</div>`;
overlay.innerHTML = `
<div class="nf-modal" role="dialog" aria-modal="true" aria-labelledby="nf-modal-title" tabindex="-1">
<div class="nf-modal-header">
<h2 id="nf-modal-title"><i class="fa fa-cog"></i> Flow Settings</h2>
<button type="button" class="nf-modal-close" aria-label="Close"><i class="fa fa-times"></i></button>
</div>
<div class="nf-modal-body">
${settingsHtml}
${dataHtml}
</div>
<div class="nf-modal-footer">
<a class="nf-footer-hint" href="https://sleazyfork.org/scripts/557178" target="_blank" rel="noopener noreferrer">
<i class="fa fa-question-circle" aria-hidden="true"></i> Help
</a>
<div class="nf-footer-actions">
<button type="button" id="nf-cancel-btn" class="nf-btn nf-btn-ghost">Cancel</button>
<button type="button" id="nf-save-settings-btn" class="nf-btn nf-btn-primary">Save</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const onKey = (e) => { if (e.key === 'Escape') closeModal(); };
const closeModal = () => {
overlay.classList.remove('active');
document.removeEventListener('keydown', onKey, true);
};
overlay._nfOpen = () => {
overlay.classList.add('active');
document.addEventListener('keydown', onKey, true);
const box = overlay.querySelector('.nf-modal');
if (box) box.focus({ preventScroll: true });
};
overlay.querySelector('.nf-modal-close').onclick = closeModal;
overlay.querySelector('#nf-cancel-btn').onclick = closeModal;
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeModal(); });
const showRange = (key, val) => {
const out = overlay.querySelector(`.nf-range-value[data-for="${key}"]`);
if (out) out.textContent = rangeFmt[key] ? rangeFmt[key](Number(val)) : String(val);
};
const syncRangeState = () => {
overlay.querySelectorAll('.nf-range-row').forEach(r => {
const dep = r.dataset.depends;
const toggle = dep && overlay.querySelector(`.nf-setting-checkbox[data-key="${dep}"]`);
r.classList.toggle('is-disabled', !!toggle && !toggle.checked);
});
};
overlay.addEventListener('input', (e) => {
if (e.target.classList && e.target.classList.contains('nf-setting-range')) showRange(e.target.dataset.key, e.target.value);
});
overlay.addEventListener('change', (e) => {
if (e.target.classList && e.target.classList.contains('nf-setting-checkbox')) syncRangeState();
});
overlay.addEventListener('click', (e) => {
const btn = e.target.closest && e.target.closest('.nf-range-reset');
if (!btn) return;
const input = overlay.querySelector(`.nf-setting-range[data-key="${btn.dataset.for}"]`);
if (input) { input.value = DEFAULT_SETTINGS[btn.dataset.for]; showRange(btn.dataset.for, input.value); }
});
overlay._nfSyncRanges = () => {
overlay.querySelectorAll('.nf-setting-range').forEach(r => {
r.value = numSetting(r.dataset.key);
showRange(r.dataset.key, r.value);
});
syncRangeState();
};
overlay.querySelector('#nf-save-settings-btn').onclick = () => {
let toggled = false;
let tuned = false;
overlay.querySelectorAll('.nf-setting-checkbox').forEach(cb => {
if (settings[cb.dataset.key] !== cb.checked) toggled = true;
settings[cb.dataset.key] = cb.checked;
});
overlay.querySelectorAll('.nf-setting-range').forEach(r => {
const v = Number(r.value);
if (numSetting(r.dataset.key) !== v) tuned = true;
settings[r.dataset.key] = v;
});
closeModal();
if (!toggled && !tuned) return;
saveSettings();
if (toggled) window.location.reload();
};
overlay.querySelector('#nf-export-btn').onclick = () => {
exportData();
};
overlay.querySelector('#nf-import-btn').onclick = () => {
importData();
};
overlay.querySelector('#nf-sync-btn').onclick = async () => {
const btn = overlay.querySelector('#nf-sync-btn');
const label = btn.querySelector('.nf-btn-label');
if (btn.disabled) return;
btn.disabled = true;
try {
const total = await syncFavorites((txt) => { label.textContent = txt; });
label.textContent = `Synced! (${total})`;
} catch (err) {
console.error('[NHentai Flow] Sync failed:', err);
label.textContent = /Login/.test(err.message) ? 'Login required' : 'Sync failed';
}
setTimeout(() => { label.textContent = 'Sync Favs'; btn.disabled = false; }, 2500);
};
}
overlay.querySelectorAll('.nf-setting-checkbox').forEach(cb => {
cb.checked = !!settings[cb.dataset.key];
});
if (overlay._nfSyncRanges) overlay._nfSyncRanges();
overlay._nfOpen();
}
// ==========================================================================
// 15. SPA ROUTER (CLEANUP & ROUTING)
// ==========================================================================
let routerInstalled = false;
function cleanupGhostElements() {
document.querySelectorAll('.btn-next-queue, .btn-random-fav').forEach(el => el.remove());
readerDirty = true;
document.querySelectorAll(`${SEL.gallery}[data-init="1"]`).forEach(gal => {
gal.removeAttribute('data-init');
gal.querySelectorAll('.reading-marker, .inline-preview-ui, .btn-view-all-popular').forEach(m => m.remove());
});
}
// ---------- Queue page: lives in its own container, Svelte keeps owning #content ----------
function isQueueView() {
return window.location.pathname === '/' && new URLSearchParams(window.location.search).get('view') === 'queue';
}
function getQueueRoot(create) {
let root = document.getElementById('nf-queue-root');
if (root && !root.isConnected) root = null;
if (!root && create) {
const content = document.querySelector(SEL.content);
if (!content || !content.parentNode) return null;
root = document.createElement('div');
root.id = 'nf-queue-root';
root.className = content.className;
content.parentNode.insertBefore(root, content.nextSibling);
}
return root;
}
function syncQueueView() {
if (isQueueView()) {
document.body.classList.add('nf-queue-view');
const root = getQueueRoot(true);
if (root && (root.dataset.rendered !== window.location.search || root.dataset.pagSig !== pagSig())) renderQueuePage();
} else {
document.body.classList.remove('nf-queue-view');
const root = document.getElementById('nf-queue-root');
if (root) root.remove();
}
}
function runRouteInit() {
captureNativePagination();
syncQueueView();
syncCommentsButton();
scan();
if (window.location.pathname === '/') initPopularShortcut();
}
function initSPARouter() {
if (routerInstalled) return;
routerInstalled = true;
let lastUrl = location.href;
const onRouteChanged = () => {
if (lastUrl === location.href) return;
lastUrl = location.href;
cleanupGhostElements();
checkPage();
smartNavOnRoute();
runRouteInit();
setTimeout(runRouteInit, 300);
setTimeout(runRouteInit, 900);
};
['pushState', 'replaceState'].forEach((fn) => {
const original = history[fn];
history[fn] = function() {
const result = original.apply(this, arguments);
setTimeout(onRouteChanged, 0);
return result;
};
});
window.addEventListener('popstate', () => setTimeout(onRouteChanged, 0));
setInterval(onRouteChanged, 200);
const observerWork = () => {
if (hasUnsyncedCards()) {
scan();
} else {
initFavoritesPage();
}
if (window.location.pathname === '/' && !document.querySelector('.btn-view-all-popular')) initPopularShortcut();
if (!document.getElementById('nf-settings-btn')) initSettingsMenu();
const form = document.querySelector(SEL.searchForm);
if (form && settings.enableSavedSearch && (!form.dataset.savedInit || !document.querySelector('.saved-search-extension'))) {
delete form.dataset.savedInit;
initSearchFlow();
}
if (!document.querySelector('.search-slash-hint')) initGlobalShortcuts();
captureNativePagination();
if (isQueueView()) syncQueueView();
syncCommentsButton();
if (isReader) {
const ic = document.querySelector(SEL.imageContainer);
if (readerDirty || !document.querySelector('.btn-fullscreen-custom') || (ic && !ic.dataset.readerInit)) initReaderMode();
} else if (settings.enableQueue && /^\/g\/\d+\/?$/.test(window.location.pathname)
&& document.querySelector(SEL.infoButtons) && !document.querySelector('.btn-queue-add')) {
initGalleryPageFeatures();
}
};
let observerPending = false;
const domObserver = new MutationObserver(() => {
if (observerPending) return;
observerPending = true;
setTimeout(() => {
observerPending = false;
try { observerWork(); } catch (err) { console.error('[NHentai Flow] observer error:', err); }
}, 120);
});
domObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
}
// ==========================================================================
// 16. INIT & OBSERVERS
// ==========================================================================
function scan() {
const step = (fn) => {
try { fn(); } catch (err) { console.error('[NHentai Flow] scan step failed:', fn.name || 'anonymous', err); }
};
step(initSearchFlow);
step(() => document.querySelectorAll(SEL.gallery).forEach(initPreviewUI));
step(initReaderMode);
step(initGalleryPageFeatures);
step(initFavoriteButtonWatcher);
step(initGlobalShortcuts);
step(initQueueWidget);
step(initRandomContextual);
step(initFavoritesPage);
step(initContextMenu);
}
let lastArrowNav = 0;
document.addEventListener('keydown', (e) => {
if (hoveredGallery && !document.fullscreenElement) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const now = performance.now();
if (now - lastArrowNav < 180) return;
lastArrowNav = now;
update(hoveredGallery, e.key === 'ArrowRight' ? 1 : -1);
}
else if (e.key === 'q') {
if (!settings.enableQueue) return;
const btn = hoveredGallery.querySelector('.queue-trigger');
if (btn) btn.click();
}
}
});
let favWatcherInstalled = false;
function cardIdFrom(el) {
let node = el;
for (let depth = 0; node && node !== document.body && depth < 8; depth++) {
node = node.parentElement;
if (!node) break;
const ids = new Set();
node.querySelectorAll(SEL.galleryLink).forEach(a => {
const m = a.getAttribute('href').match(/\/g\/(\d+)/);
if (m) ids.add(m[1]);
});
if (ids.size === 1) return [...ids][0];
if (ids.size > 1) return null;
}
return null;
}
function initFavClickWatcher() {
if (favWatcherInstalled) return;
favWatcherInstalled = true;
window.addEventListener('click', (e) => {
if (!e.target || !e.target.closest) return;
const removeBtn = e.target.closest(SEL.removeButton);
if (!removeBtn) return;
const id = cardIdFrom(removeBtn);
if (!id) return;
removeFav(id);
}, true);
}
function boot() {
const steps = [ensureCdn, initFavClickWatcher, initSettingsMenu, runRouteInit, initSPARouter, initSmartNavigation];
steps.forEach(fn => {
try { fn(); } catch (err) { console.error('[NHentai Flow] boot step failed:', fn.name, err); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();