Modern dark theme with masonry layout, advanced search overlay, smart image loading, and collapsible sidebar
// ==UserScript==
// @name Rule34 Enhanced Dark Gallery
// @namespace ko-fi.com/awesome97076
// @version 5
// @description Modern dark theme with masonry layout, advanced search overlay, smart image loading, and collapsible sidebar
// @author Awesome
// @match https://rule34.xxx/*
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=rule34.xxx
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-end
// @noframes
// ==/UserScript==
(function() {
"use strict";
const DEFAULTS = {
debug: false,
searchOverlayEnabled: true,
searchOverlayHotkey: "/",
imageReplacement: true,
sidebarCollapsible: true,
sidebarRememberState: true,
defaultColumns: 0,
imageServerBase: "",
imageServerExpiry: 0
};
const CFG = {};
for (const [k, v] of Object.entries(DEFAULTS)) {
CFG[k] = GM_getValue(k, v);
}
function cfgSet(key, val) {
CFG[key] = val;
GM_setValue(key, val);
}
function cfgToggle(key) {
cfgSet(key, !CFG[key]);
location.reload();
}
// ───────────────
// MENU COMMANDS
// ───────────────
function registerMenuCommands() {
const flag = v => v ? "\u2705" : "\u274C";
GM_registerMenuCommand(`${flag(CFG.imageReplacement)} Image Replacement`, () => cfgToggle("imageReplacement"));
GM_registerMenuCommand(`${flag(CFG.searchOverlayEnabled)} Search Overlay`, () => cfgToggle("searchOverlayEnabled"));
GM_registerMenuCommand(`${flag(CFG.sidebarCollapsible)} Collapsible Sidebar`, () => cfgToggle("sidebarCollapsible"));
GM_registerMenuCommand(`${flag(CFG.sidebarRememberState)} Remember Sidebar State`, () => cfgToggle("sidebarRememberState"));
GM_registerMenuCommand(`${flag(CFG.debug)} Debug Logging`, () => cfgToggle("debug"));
GM_registerMenuCommand("\uD83D\uDD04 Reset Image Server Cache", () => {
cfgSet("imageServerBase", "");
cfgSet("imageServerExpiry", 0);
location.reload();
});
GM_registerMenuCommand("\u2699\uFE0F Set Default Columns\u2026", () => {
const input = prompt(
"Default column count (1\u20138).\nSet 0 for automatic responsive breakpoints.\nCurrent: " + (CFG.defaultColumns || "auto"),
CFG.defaultColumns
);
if (input === null) return;
const n = parseInt(input, 10);
if (isNaN(n) || n < 0 || n > 8) { alert("Invalid value"); return; }
cfgSet("defaultColumns", n);
location.reload();
});
}
registerMenuCommands();
// ───────────────────────────────────────────────
// UTILITIES
// ───────────────────────────────────────────────
const Utils = {
log: (...args) => CFG.debug && console.log("[Rule34 Enhanced]", ...args),
createElement: (tag, props = {}, children = []) => {
const el = document.createElement(tag);
Object.assign(el, props);
if (typeof children === "string") el.innerHTML = children;
else children.forEach(c => el.appendChild(c));
return el;
},
debounce: (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; },
throttle: (fn, ms) => { let busy; return (...a) => { if (!busy) { fn(...a); busy = true; setTimeout(() => { busy = false; }, ms); } }; }
};
const ImageServer = {
CACHE_TTL: 7 * 24 * 60 * 60 * 1000,
// Servers known NOT to host /images/
THUMBS_ONLY: new Set(["miami.rule34.xxx", "ny.rule34.xxx"]),
resolve() {
// 1. Cached and not expired
if (CFG.imageServerBase && Date.now() < CFG.imageServerExpiry) {
Utils.log("Image server (cached):", CFG.imageServerBase);
return CFG.imageServerBase;
}
// 2. Derive from thumbnail URLs on the page (skip thumbs-only servers)
const fromPage = this.detectFromPage();
if (fromPage) { this.cache(fromPage); return fromPage; }
// 3. Hardcoded fallback
const fallback = "https://wimg.rule34.xxx/images";
this.cache(fallback);
return fallback;
},
detectFromPage() {
const thumb = document.querySelector('img[src*="/thumbnails/"]');
if (!thumb) return null;
try {
const url = new URL(thumb.src);
if (this.THUMBS_ONLY.has(url.host)) {
Utils.log("Thumbnail server is thumbs-only, skipping:", url.host);
return null;
}
const base = `${url.protocol}//${url.host}/images`;
Utils.log("Image server (page-detected):", base);
return base;
} catch { return null; }
},
cache(base) {
cfgSet("imageServerBase", base);
cfgSet("imageServerExpiry", Date.now() + this.CACHE_TTL);
}
};
const StylesManager = {
init() {
const style = document.createElement("style");
style.textContent = this.getCSS();
document.head.appendChild(style);
},
getCSS() {
return `
:root {
--bg-color: #121212;
--bg-secondary: #1e1e1e;
--bg-tertiary: #2d2d2d;
--accent-color: #9c64a6;
--accent-secondary: #ae81ff;
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-muted: #707070;
--border-color: rgba(255, 255, 255, 0.1);
--tag-artist: #ff79c6;
--tag-character: #50fa7b;
--tag-copyright: #bd93f9;
--tag-metadata: #f1fa8c;
--grid-columns: 3;
--grid-gap: 8px;
--border-radius: 6px;
--box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--font-size-base: 14px;
}
html, body {
margin: 0 !important;
padding: 0 !important;
font-size: var(--font-size-base) !important;
background: var(--bg-color) !important;
color: var(--text-primary) !important;
overflow-x: hidden;
min-height: 100%;
}
*, *::before, *::after { box-sizing: border-box; }
.tag-count { color: var(--text-primary) !important; }
#post-list > div.content > span { display: none; }
div#content {
width: 100% !important;
max-width: 100% !important;
margin: 0 auto !important;
padding: 8px 20px 30px 20px !important;
}
/* ======== HEADER ======== */
div#header {
background: var(--bg-secondary) !important;
padding: 0 !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
position: sticky;
top: 0;
z-index: 1000;
margin-bottom: 16px !important;
}
div#header #site-title {
padding: 6px 26px !important;
}
div#header #site-title a {
color: var(--accent-color) !important;
font-size: 22px !important;
font-weight: bold;
text-shadow: none !important;
}
div#header ul#navbar, div#header ul#subnavbar {
background: var(--bg-secondary) !important;
background-image: none !important;
flex-wrap: wrap;
align-items: center;
list-style: none;
padding: 10px 16px !important;
margin: 0 !important;
border-bottom: 1px solid var(--border-color);
}
#rmainmenu:checked ~ #navbar, #rsubmenu:checked ~ #subnavbar { display: flex; }
div#header ul#navbar li, div#header ul#subnavbar li {
background: none !important;
background-image: none !important;
}
div#header ul#navbar li a, div#header ul#subnavbar li a {
padding: 12px !important;
color: var(--text-secondary) !important;
text-decoration: none;
white-space: nowrap;
font-size: 14px !important;
font-weight: normal !important;
}
div#header ul#navbar li a:hover, div#header ul#subnavbar li a:hover {
color: var(--accent-color) !important;
}
div#header ul#navbar li.current-page {
background-image: none !important;
background-color: transparent !important;
}
div#header ul#navbar li.current-page a {
color: var(--accent-color) !important;
font-weight: 600 !important;
border-bottom: 2px solid var(--accent-color);
}
/* ======== LINKS ======== */
a:link, a:visited { color: var(--accent-color) !important; text-decoration: none !important; }
a:hover { color: var(--accent-secondary) !important; text-decoration: underline !important; }
a:active { color: var(--accent-secondary) !important; text-decoration: none !important; }
.tag-type-artist > a, .tag-type-artist { color: var(--tag-artist) !important; }
.tag-type-character > a, .tag-type-character { color: var(--tag-character) !important; }
.tag-type-copyright > a, .tag-type-copyright { color: var(--tag-copyright) !important; }
.tag-type-metadata > a, .tag-type-metadata { color: var(--tag-metadata) !important; }
/* ======== GENERIC TABLE STYLING ======== */
table, table.highlightable {
background: var(--bg-secondary) !important;
border-collapse: collapse !important;
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--border-color) !important;
width: 100%;
max-width: 700px;
}
table td, table th {
padding: 10px 16px !important;
border-bottom: 1px solid var(--border-color) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
vertical-align: top;
}
table td:first-child { color: var(--text-secondary) !important; font-weight: 600; white-space: nowrap; }
table td strong { color: var(--text-secondary) !important; }
table tr:last-child td { border-bottom: none !important; }
table tr:hover { background: rgba(255, 255, 255, 0.03) !important; }
table tr:nth-child(odd) { background: rgba(255, 255, 255, 0.02) !important; }
table.highlightable > tbody > tr:hover { background: rgba(156, 100, 166, 0.1) !important; }
table.highlightable th { color: var(--text-primary) !important; background: var(--bg-tertiary) !important; }
tr.tableheader, thead tr { background: var(--bg-tertiary) !important; background-image: none !important; }
/* ======== PROFILE PAGE ======== */
div#content > h2 {
color: var(--text-primary) !important;
font-size: 22px;
font-weight: 700;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid var(--accent-color);
display: inline-block;
}
div#content h4 {
color: var(--text-primary) !important;
font-size: 16px;
font-weight: 600;
margin: 16px 0 12px 0;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
div#content h4 a { margin-left: 6px; font-size: 13px; }
/* Profile page layout – make favorites section wider */
div#content > div[style*="float: left"] {
float: none !important;
clear: both !important;
width: 100% !important;
max-width: 100% !important;
}
/* ================================================================
IMAGE GRID – CSS Columns masonry
Uses column-count for true browser-native masonry layout.
No JS measurement needed – eliminates overlap and gap issues.
Site default: div.image-list { display: flex; flex-flow: wrap; }
Site default: .thumb { width: 200px; height: 200px; display: flex; }
All overridden with !important.
================================================================ */
div.image-list {
display: block !important;
column-count: var(--grid-columns) !important;
column-gap: var(--grid-gap) !important;
flex-flow: unset !important;
flex-wrap: unset !important;
flex-direction: unset !important;
justify-content: unset !important;
align-items: unset !important;
align-content: unset !important;
width: 100% !important;
margin: 0 auto !important;
padding: 0 !important;
}
/* Override the site's fixed 200x200 .thumb sizing */
span.thumb, .thumb {
width: 100% !important;
height: auto !important;
display: inline-block !important;
margin: 0 0 var(--grid-gap) 0 !important;
padding: 0 !important;
overflow: hidden;
break-inside: avoid !important;
justify-content: unset !important;
align-items: unset !important;
}
/* Profile page wrapper spans (the outer span with inline grid style) */
div#content > div[style*="float: left"] span[style*="display: grid"] {
display: inline-block !important;
grid-template-rows: unset !important;
width: 100% !important;
break-inside: avoid !important;
margin-bottom: var(--grid-gap) !important;
}
span.thumb > a, .thumb > a {
display: block !important;
position: relative;
overflow: hidden;
border-radius: 6px;
background: var(--bg-tertiary);
box-shadow: var(--box-shadow);
width: 100% !important;
height: auto !important;
text-align: unset !important;
justify-content: unset !important;
align-items: unset !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
span.thumb > a:hover, .thumb > a:hover {
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 100;
}
span.thumb img, span.thumb video,
.thumb img, .thumb video {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
max-height: unset !important;
display: block !important;
object-fit: contain;
border-radius: 6px;
border: none !important;
}
img.preview {
margin: 0 !important;
}
.webm-thumb {
border: 2px solid #8e44ad !important;
border-radius: 6px !important;
}
/* ======== COMMENT LISTS ======== */
div#comments div#comment-list {
display: flex !important;
flex-direction: column !important;
gap: 16px;
}
/* Each post card in the comment list */
div#comments div#comment-list > div.post {
float: none !important;
clear: none !important;
display: flex !important;
flex-direction: row !important;
background: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius) !important;
overflow: hidden !important;
margin-bottom: 0 !important;
}
/* Thumbnail column */
div#comments div#comment-list > div.post > div.col1 {
float: none !important;
clear: none !important;
flex-shrink: 0 !important;
width: 680px !important;
min-width: 680px !important;
padding: 12px !important;
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
background: var(--bg-tertiary) !important;
}
div#comments div#comment-list > div.post > div.col1 a {
display: block;
border-radius: var(--border-radius);
overflow: hidden;
}
div#comments div#comment-list > div.post > div.col1 img {
width: 100% !important;
height: auto !important;
max-width: 656px !important;
max-height: unset !important;
display: block !important;
border-radius: var(--border-radius);
transition: transform 0.2s ease;
object-fit: contain !important;
}
div#comments div#comment-list > div.post > div.col1 img:hover {
transform: scale(1.05);
}
/* Info + comments column */
div#comments div#comment-list > div.post > div.col2 {
float: none !important;
flex: 1 !important;
min-width: 0 !important;
width: auto !important;
display: flex !important;
flex-direction: column !important;
}
div#comments div#comment-list > div.post > div.col2 > div.header {
background: var(--bg-tertiary);
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0 !important;
}
div#comments div#comment-list > div.post > div.col2 > div.header .info {
margin-right: 16px;
font-size: 13px;
color: var(--text-secondary) !important;
}
div#comments div#comment-list > div.post > div.col2 > div.header .info strong {
color: var(--text-muted) !important;
font-weight: 600;
margin-right: 4px;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
div#comments div#comment-list > div.post > div.col2 > div.header .tags {
margin-top: 8px;
font-size: 12px;
line-height: 1.8;
max-width: 100% !important;
width: 100% !important;
overflow: hidden;
}
div#comments div#comment-list > div.post > div.col2 > div.header .tags strong {
color: var(--text-muted) !important;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
div#comments div#comment-list > div.post > div.col2 > div.header .tags a {
font-size: 12px;
padding: 1px 4px;
border-radius: 3px;
transition: background 0.15s ease;
}
div#comments div#comment-list > div.post > div.col2 > div.header .tags a:hover {
background: rgba(255, 255, 255, 0.08);
text-decoration: none !important;
}
/* Comment responses area */
div.response-list {
padding: 8px 0 !important;
display: flex !important;
flex-direction: column !important;
gap: 2px;
}
div.response-list > div.post {
float: none !important;
clear: none !important;
display: flex !important;
flex-direction: row !important;
padding: 10px 16px;
gap: 12px;
margin-bottom: 0 !important;
background: transparent;
transition: background 0.15s ease;
}
div.response-list > div.post:hover {
background: rgba(255, 255, 255, 0.03);
}
div.response-list > div.post > div.author {
float: none !important;
flex-shrink: 0;
min-width: 130px !important;
max-width: 160px !important;
padding-right: 12px !important;
border-right: 2px solid var(--border-color);
overflow-x: visible !important;
}
div.response-list > div.post > div.author h6 {
margin: 0;
font-size: 13px !important;
font-weight: 600;
}
div.response-list > div.post > div.author h6 a { color: var(--accent-color) !important; }
div.response-list > div.post > div.author span.date {
display: block;
font-size: 11px;
color: var(--text-muted) !important;
margin-top: 2px;
}
div.response-list > div.post > div.content {
float: none !important;
flex: 1 !important;
min-width: 0;
width: auto !important;
padding: 0 !important;
margin: 0 !important;
}
div.response-list > div.post > div.content > div.body {
color: var(--text-primary) !important;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
}
div.response-list > div.post > div.content > div.footer {
margin-top: 6px !important;
font-size: 11px;
color: var(--text-muted) !important;
}
div.response-list > div.post > div.content > div.footer a {
color: var(--text-muted) !important;
font-size: 11px;
}
div.response-list > div.post > div.content > div.footer a:hover {
color: #ff5555 !important;
}
/* Vote links in comment lists */
div#comments a[onclick*="post_vote"],
div#post-comments a[onclick*="post_vote"] {
color: var(--accent-secondary) !important;
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
transition: background 0.15s ease;
}
div#comments a[onclick*="post_vote"]:hover,
div#post-comments a[onclick*="post_vote"]:hover {
background: rgba(174, 129, 255, 0.15);
text-decoration: none !important;
}
/* ======== POST VIEW PAGE – comments below post ======== */
div#post-comments {
max-width: 100% !important;
}
div#post-comments div#comment-list > div {
margin-bottom: 1em;
}
div#post-comments div#comment-list > div > div.col2 {
margin-top: 10px;
width: auto !important;
}
div#post-comments div#comment-list > div > div.col2 > div.header {
margin-bottom: 1em;
}
/* ======== PAGINATION ======== */
div#paginator {
display: block !important;
text-align: center;
padding: 16px 0 !important;
margin-top: 16px;
clear: both;
}
div#paginator a, div#paginator b, div#paginator span {
display: inline-block !important;
padding: 6px 12px !important;
margin: 2px 3px !important;
border-radius: var(--border-radius) !important;
font-size: 13px;
font-weight: 500;
text-decoration: none !important;
transition: all 0.15s ease;
}
div#paginator a {
background: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
border: 1px solid var(--border-color) !important;
}
div#paginator a:hover {
background: var(--accent-color) !important;
color: white !important;
border-color: var(--accent-color) !important;
}
div#paginator b {
background: var(--accent-color) !important;
color: white !important;
border: 1px solid var(--accent-color) !important;
}
/* ======== NOTICE / MAIL ======== */
div#notice.notice, div.notice {
background: var(--bg-secondary) !important;
border: 1px solid var(--accent-color) !important;
border-radius: var(--border-radius);
color: var(--text-primary) !important;
padding: 12px 16px !important;
margin-bottom: 12px;
}
div.has-mail {
background: linear-gradient(135deg, rgba(156, 100, 166, 0.15), rgba(174, 129, 255, 0.1)) !important;
border: 1px solid var(--accent-color) !important;
border-radius: var(--border-radius);
padding: 10px 16px !important;
margin-bottom: 12px;
text-align: center;
}
div.has-mail a { color: var(--accent-secondary) !important; font-weight: 600; }
div#long-notice { background: var(--bg-secondary) !important; color: var(--text-primary) !important; border-radius: var(--border-radius); }
/* ======== FORMS ======== */
input[type="text"], input[type="password"], input[type="email"],
input[type="search"], select, textarea {
background: var(--bg-color) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius);
padding: 8px 12px;
font-size: 14px;
transition: border-color 0.2s ease;
}
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
input[type="search"]:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 3px rgba(156, 100, 166, 0.15);
background: var(--bg-color) !important;
}
input[type="submit"], input[type="button"], button {
background: var(--accent-color) !important;
color: white !important;
border: none !important;
border-radius: var(--border-radius);
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
input[type="submit"]:hover, input[type="button"]:hover, button:hover {
background: var(--accent-secondary) !important;
box-shadow: 0 2px 8px rgba(156, 100, 166, 0.3);
}
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent-color); }
/* ======== WIKI / HELP / FORUM ======== */
div#content .section, div#content fieldset {
background: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius);
padding: 16px;
margin-bottom: 12px;
}
div#content fieldset legend { color: var(--accent-color) !important; font-weight: 600; padding: 0 8px; }
div.help div.code { border-color: var(--border-color) !important; background: var(--bg-tertiary) !important; }
div.help h4 { color: var(--accent-color) !important; }
div.quote { background: var(--bg-tertiary) !important; border-color: var(--border-color) !important; color: var(--text-primary) !important; }
div.status-notice { border-color: var(--border-color) !important; background: var(--bg-secondary) !important; color: var(--text-primary) !important; }
/* Wiki */
div#wiki-show > div#body { color: var(--text-primary) !important; width: auto !important; }
div#wiki-show > div#body > div#byline { color: var(--text-muted) !important; }
div.wiki > h2.title { color: var(--accent-color) !important; }
div#wiki-diff del { background-color: rgba(255, 85, 85, 0.3) !important; }
div#wiki-diff ins { background-color: rgba(80, 250, 123, 0.3) !important; }
/* Forum */
div#forum { color: var(--text-primary) !important; }
/* Mail */
div.mailbody { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; color: var(--text-primary) !important; }
div.mailbuttons { color: var(--text-secondary) !important; }
/* ======== SIDEBAR ======== */
div.sidebar {
background: var(--bg-secondary) !important;
border-radius: 8px;
box-shadow: var(--box-shadow);
padding: 0 !important;
margin-right: 16px !important;
margin-bottom: 16px;
max-width: 260px !important;
min-width: 240px !important;
border: 1px solid var(--border-color);
overflow: hidden !important;
}
div.sidebar li {
color: var(--text-primary) !important;
}
#tag-sidebar {
margin: 0; padding: 0; list-style: none;
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--accent-color) var(--bg-tertiary);
}
#tag-sidebar h6 {
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
margin: 0;
padding: 10px 16px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
#tag-sidebar h6:hover { background: rgba(156, 100, 166, 0.2) !important; color: var(--accent-color) !important; }
#tag-sidebar h6::after {
content: '';
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid currentColor;
opacity: 0.7;
}
#tag-sidebar h6.collapsed::after { transform: rotate(-90deg); }
.tag-section { overflow: hidden; opacity: 1; }
.tag-section.collapsed { max-height: 0; opacity: 0; }
#tag-sidebar li { padding: 0; margin: 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
#tag-sidebar li:not(:has(h6)):hover { background: rgba(255, 255, 255, 0.03); }
/* ======== FOOTER ======== */
div#footer {
margin-top: 30px !important;
padding: 16px 0;
text-align: center;
color: var(--text-muted) !important;
border-top: 1px solid var(--border-color);
font-size: 12px;
}
/* ======== POST VIEW / POST LIST LAYOUT ======== */
div#post-view, div#post-list, div.flexi {
display: flex !important;
flex-direction: row !important;
}
/* ======== TAG SEARCH BOX ======== */
div.tag-search {
background: var(--bg-tertiary) !important;
padding: 12px !important;
border-radius: var(--border-radius);
margin: 0 0 16px 0 !important;
width: 100%;
position: relative;
}
div.tag-search input[type="text"] {
width: 100% !important;
padding: 8px 12px !important;
background: var(--bg-color) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius);
color: var(--text-primary) !important;
font-size: 14px;
margin-bottom: 8px;
}
div.tag-search input[type="submit"] {
padding: 8px 16px !important;
background: var(--accent-color) !important;
color: white !important;
border: none !important;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: bold;
font-size: 14px;
width: 100%;
max-width: 180px;
margin-top: 3px;
}
div.tag-search input[type="submit"]:hover { background: var(--accent-secondary) !important; }
/* Autocomplete (awesomplete) overrides */
.awesomplete > ul {
background: var(--bg-secondary) !important;
border: 1px solid var(--accent-color) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
.awesomplete > ul:before {
background: var(--bg-secondary) !important;
border-color: var(--accent-color) !important;
}
.awesomplete > ul > li {
color: var(--text-primary) !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--border-color);
}
.awesomplete > ul > li:hover,
.awesomplete > ul > li[aria-selected="true"] {
background: rgba(156, 100, 166, 0.2) !important;
color: var(--text-primary) !important;
}
.awesomplete mark {
background: rgba(156, 100, 166, 0.4) !important;
color: white !important;
}
/* ======== SEARCH OVERLAY ======== */
.r34-search-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
}
.r34-search-overlay.show { opacity: 1; visibility: visible; }
.r34-search-modal {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid var(--border-color);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: visible;
}
.r34-search-header {
background: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-secondary) 100%);
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 12px 12px 0 0;
}
.r34-search-title {
color: white !important;
font-size: 18px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.r34-search-title::before { content: "\\26A1"; font-size: 20px; }
.r34-search-close {
background: rgba(255, 255, 255, 0.15) !important;
color: white !important;
border: none !important;
border-radius: 6px;
width: 28px; height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
font-weight: bold;
padding: 0 !important;
}
.r34-search-close:hover { background: rgba(255, 85, 85, 0.8) !important; }
.r34-search-body { padding: 20px; }
.r34-search-form { display: flex; flex-direction: column; gap: 16px; }
.r34-search-input {
width: 100%;
padding: 12px 16px !important;
background: var(--bg-color) !important;
border: 2px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px;
color: var(--text-primary) !important;
font-size: 14px;
resize: vertical;
min-height: 60px;
line-height: 1.4;
}
.r34-search-input:focus {
outline: none;
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 3px rgba(156, 100, 166, 0.15);
}
.r34-search-input::placeholder { color: var(--text-muted) !important; font-style: italic; }
.r34-search-button {
padding: 12px 20px !important;
background: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-secondary) 100%) !important;
color: white !important;
border: none !important;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.r34-search-button:hover { box-shadow: 0 4px 12px rgba(156, 100, 166, 0.4); }
.r34-search-hint { color: var(--text-muted) !important; font-size: 12px; text-align: center; margin-top: 8px; font-style: italic; }
.r34-search-shortcut {
background: var(--bg-tertiary);
color: var(--text-secondary) !important;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin: 0 4px;
}
.r34-search-overlay-trigger {
position: absolute;
top: 8px; right: 8px;
background: var(--accent-color) !important;
color: white !important;
border: none !important;
border-radius: 50%;
width: 32px; height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
z-index: 10;
padding: 0 !important;
}
.r34-search-overlay-trigger:hover { background: var(--accent-secondary) !important; }
.mobile-search-link {
display: flex !important;
align-items: center;
gap: 6px;
font-weight: 600 !important;
color: var(--accent-color) !important;
}
/* ======== AUTOCOMPLETE DROPDOWN (search overlay) ======== */
.r34-autocomplete-dropdown {
position: fixed;
background: var(--bg-color) !important;
border: 2px solid var(--accent-color);
border-top: none;
border-radius: 0 0 8px 8px;
max-height: 250px;
overflow-y: auto;
z-index: 10001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
scrollbar-width: thin;
scrollbar-color: var(--accent-color) var(--bg-tertiary);
}
.r34-autocomplete-item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
}
.r34-autocomplete-item:hover, .r34-autocomplete-item.selected { background: rgba(156, 100, 166, 0.15); }
.r34-autocomplete-tag { font-size: 13px; font-weight: 500; flex: 1; margin-right: 8px; }
.r34-autocomplete-count { font-size: 11px; color: var(--text-muted); margin-right: 8px; }
.r34-autocomplete-type {
font-size: 9px; padding: 2px 6px; border-radius: 8px;
text-transform: uppercase; font-weight: bold; min-width: 40px; text-align: center;
}
.r34-tag-artist { border-left: 3px solid var(--tag-artist); }
.r34-tag-artist .r34-autocomplete-tag { color: var(--tag-artist); }
.r34-tag-artist .r34-autocomplete-type { background: rgba(255, 121, 198, 0.2); color: var(--tag-artist); }
.r34-tag-character { border-left: 3px solid var(--tag-character); }
.r34-tag-character .r34-autocomplete-tag { color: var(--tag-character); }
.r34-tag-character .r34-autocomplete-type { background: rgba(80, 250, 123, 0.2); color: var(--tag-character); }
.r34-tag-copyright { border-left: 3px solid var(--tag-copyright); }
.r34-tag-copyright .r34-autocomplete-tag { color: var(--tag-copyright); }
.r34-tag-copyright .r34-autocomplete-type { background: rgba(189, 147, 249, 0.2); color: var(--tag-copyright); }
.r34-tag-metadata { border-left: 3px solid var(--tag-metadata); }
.r34-tag-metadata .r34-autocomplete-tag { color: var(--tag-metadata); }
.r34-tag-metadata .r34-autocomplete-type { background: rgba(241, 250, 140, 0.2); color: var(--tag-metadata); }
.r34-tag-general { border-left: 3px solid #b0b0b0; }
.r34-tag-general .r34-autocomplete-tag { color: #b0b0b0; }
.r34-tag-general .r34-autocomplete-type { background: rgba(176, 176, 176, 0.2); color: #b0b0b0; }
/* ======== COLUMN CONTROL ======== */
.r34-column-control {
margin-bottom: 16px;
background: var(--bg-secondary);
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--box-shadow);
}
.r34-column-control label { color: var(--text-primary) !important; font-weight: 600; min-width: 60px; font-size: 14px; }
.r34-column-control input[type="range"] {
flex: 1;
height: 6px;
background: var(--bg-tertiary) !important;
border-radius: 3px;
outline: none;
cursor: pointer;
border: none !important;
}
.r34-column-count { color: var(--accent-color); font-weight: bold; font-size: 16px; min-width: 20px; text-align: center; }
/* ======== MISC ======== */
span.data-nosnippet { display: none; }
#navbar li a[rel="sponsored"] { opacity: 0.5; font-size: 12px !important; }
#navbar li a[rel="sponsored"]:hover { opacity: 0.8; }
hr { border-color: var(--border-color) !important; }
/* GDPR */
.gdprinner { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; color: var(--text-primary) !important; }
.gdprtext { color: var(--text-primary) !important; }
.gdprtext a { color: var(--accent-color) !important; }
/* Tips bar */
div.tips { background: var(--bg-tertiary) !important; background-image: none !important; border-bottom-color: var(--border-color) !important; color: var(--text-primary) !important; }
/* Spoilers */
a.spoiler { color: var(--bg-color) !important; background: var(--bg-color) !important; }
a.spoiler:hover { color: var(--text-primary) !important; }
span.spoiler { color: var(--bg-color) !important; background: var(--bg-color) !important; }
span.spoiler:hover { color: var(--text-primary) !important; }
/* Blocked */
div.blocked { border-color: #ff5555 !important; background: rgba(255, 85, 85, 0.1) !important; color: #ff5555 !important; }
/* Notes on images */
div#note-container > div.note-body { background: var(--bg-secondary) !important; border-color: var(--border-color) !important; color: var(--text-primary) !important; }
div#note-container > div.note-box { border-color: var(--accent-color) !important; background: rgba(156, 100, 166, 0.15) !important; }
/* Manual page chooser */
.manual-page-chooser > input[type="text"] { background-color: var(--bg-color) !important; }
.manual-page-chooser > input[type="submit"] { background-color: var(--accent-color) !important; }
/* ======== RESPONSIVE ======== */
@media (min-width: 2560px) { :root { --grid-columns: 6; } }
@media (max-width: 2559px) and (min-width: 1920px) { :root { --grid-columns: 5; } }
@media (max-width: 1919px) and (min-width: 1600px) { :root { --grid-columns: 4; } }
@media (max-width: 1599px) and (min-width: 1200px) { :root { --grid-columns: 4; } }
@media (max-width: 1199px) and (min-width: 992px) { :root { --grid-columns: 3; } }
@media (max-width: 991px) and (min-width: 768px) { :root { --grid-columns: 3; } }
@media (max-width: 767px) and (min-width: 576px) { :root { --grid-columns: 2; } }
@media (max-width: 575px) { :root { --grid-columns: 1; } }
@media (max-width: 768px) {
span.thumb > a:hover, .thumb > a:hover { transform: scale(1.02) !important; }
div#header ul#navbar { flex-direction: column; align-items: stretch; padding: 0 !important; }
div#header ul#navbar li { display: block !important; border-top: 1px solid var(--border-color) !important; padding: 0 10px !important; margin: 0 !important; }
div#header ul#navbar li a { font-size: 14px !important; width: 100% !important; display: block !important; padding: 12px 0 !important; }
.mobile-search-link { background: var(--accent-color) !important; color: white !important; padding: 8px 12px !important; border-radius: 6px !important; margin: 2px 0 !important; }
.r34-column-control { padding: 10px !important; margin-bottom: 12px !important; }
/* Comments responsive */
div#comments div#comment-list > div.post { flex-direction: column !important; }
div#comments div#comment-list > div.post > div.col1 { width: 100% !important; min-width: 100% !important; max-height: 200px; overflow: hidden; }
div#comments div#comment-list > div.post > div.col1 img { max-width: 150px !important; }
div.response-list > div.post { flex-direction: column !important; gap: 4px !important; }
div.response-list > div.post > div.author {
width: auto !important; min-width: unset !important; max-width: unset !important;
border-right: none !important; border-bottom: 1px solid var(--border-color);
padding-right: 0 !important; padding-bottom: 6px !important;
display: flex; align-items: baseline; gap: 8px;
}
div#comments div#comment-list > div.post > div.col2 > div.header .tags { display: none; }
}
@media (max-width: 575px) {
div#content { padding: 8px !important; }
.r34-search-modal { width: 95% !important; margin: 8px !important; }
.r34-search-body { padding: 16px !important; }
div.sidebar { margin-right: 0 !important; max-width: 100% !important; min-width: 100% !important; margin-bottom: 16px !important; }
#tag-sidebar { max-height: 40vh !important; }
div#header #site-title { padding: 10px 12px !important; text-align: center !important; }
div#header #site-title a { font-size: 18px !important; }
}
`;
}
};
// SEARCH OVERLAY
const SearchOverlay = {
overlay: null,
isOpen: false,
autocompleteCache: new Map(),
currentSuggestions: [],
selectedSuggestionIndex: -1,
pendingRequest: null,
lastQuery: "",
init() {
if (!CFG.searchOverlayEnabled) return;
setTimeout(() => {
this.createOverlay();
this.bindEvents();
this.addTriggerButton();
}, 100);
},
createOverlay() {
this.overlay = Utils.createElement("div", {
className: "r34-search-overlay",
innerHTML: `
<div class="r34-search-modal">
<div class="r34-search-header">
<h3 class="r34-search-title">Enhanced Search</h3>
<div class="r34-search-controls">
<button class="r34-search-close" type="button" title="Close">×</button>
</div>
</div>
<div class="r34-search-body">
<form class="r34-search-form" action="index.php" method="get">
<input type="hidden" name="page" value="post">
<input type="hidden" name="s" value="list">
<div class="r34-search-input-container" style="position: relative;">
<textarea name="tags" class="r34-search-input" placeholder="Enter search tags... Examples: \u2022 female solo animated \u2022 character_name -furry rating:safe \u2022 artist_name 1girl" rows="4"></textarea>
<div class="r34-autocomplete-dropdown" style="display: none;"></div>
</div>
<button type="submit" class="r34-search-button">Search</button>
<div class="r34-search-hint">
Press <span class="r34-search-shortcut">/</span> to open
<span class="r34-search-shortcut">Esc</span> to close
<span class="r34-search-shortcut">\u2191\u2193</span> navigate
<span class="r34-search-shortcut">Enter</span> select
</div>
</form>
</div>
</div>
`
});
document.body.appendChild(this.overlay);
},
bindEvents() {
if (!this.overlay) return;
const closeBtn = this.overlay.querySelector(".r34-search-close");
const textarea = this.overlay.querySelector(".r34-search-input");
const dropdown = this.overlay.querySelector(".r34-autocomplete-dropdown");
closeBtn?.addEventListener("click", () => this.close());
this.overlay.addEventListener("click", e => { if (e.target === this.overlay) this.close(); });
document.addEventListener("keydown", e => {
const inInput = document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA";
if (e.key === CFG.searchOverlayHotkey && !this.isOpen && !inInput) { e.preventDefault(); this.open(); }
else if (e.key === "Escape" && this.isOpen) { e.preventDefault(); this.close(); }
}, true);
if (textarea) {
textarea.addEventListener("input", Utils.debounce(e => this.handleInput(e), 300));
textarea.addEventListener("keydown", e => this.handleKeyDown(e));
textarea.addEventListener("blur", () => { this.cancelPendingRequest(); setTimeout(() => this.hideAutocomplete(), 150); });
this.populateCurrentSearch();
}
dropdown?.addEventListener("click", e => {
const item = e.target.closest(".r34-autocomplete-item");
if (item) this.selectSuggestion(item.dataset.value);
});
},
async handleInput(e) {
const ta = e.target;
const tag = this.getCurrentTag(ta.value, ta.selectionStart);
if (!tag || tag.length < 2 || tag === this.lastQuery) { this.hideAutocomplete(); return; }
this.lastQuery = tag;
this.cancelPendingRequest();
await this.showAutocomplete(tag);
},
cancelPendingRequest() {
if (this.pendingRequest) { this.pendingRequest.cancelled = true; this.pendingRequest = null; }
},
async fetchSuggestions(query) {
if (this.autocompleteCache.has(query)) return this.autocompleteCache.get(query);
this.cancelPendingRequest();
try {
const req = fetch(`https://ac.rule34.xxx/autocomplete.php?q=${encodeURIComponent(query)}`);
this.pendingRequest = { promise: req, cancelled: false };
const resp = await req;
if (this.pendingRequest?.cancelled || !resp.ok) return [];
const json = await resp.json();
if (this.pendingRequest?.cancelled) return [];
const suggestions = json.slice(0, 6).map(item => ({
label: item.label, value: item.value, type: item.type,
count: this.extractCount(item.label)
}));
this.autocompleteCache.set(query, suggestions);
if (this.autocompleteCache.size > 30) this.cleanupCache();
this.pendingRequest = null;
return suggestions;
} catch { this.pendingRequest = null; return []; }
},
cleanupCache() {
const keep = Array.from(this.autocompleteCache.entries()).slice(0, 20);
this.autocompleteCache.clear();
keep.forEach(([k, v]) => this.autocompleteCache.set(k, v));
},
async showAutocomplete(query) {
const suggestions = await this.fetchSuggestions(query);
const dropdown = this.overlay.querySelector(".r34-autocomplete-dropdown");
const textarea = this.overlay.querySelector(".r34-search-input");
if (!dropdown || !textarea || !suggestions.length) { this.hideAutocomplete(); return; }
const rect = textarea.getBoundingClientRect();
dropdown.style.left = rect.left + "px";
dropdown.style.top = rect.bottom + "px";
dropdown.style.width = rect.width + "px";
this.currentSuggestions = suggestions;
this.selectedSuggestionIndex = -1;
dropdown.innerHTML = suggestions.map((item, i) => {
const countDisplay = item.count > 0 ? `(${item.count})` : "";
return `<div class="r34-autocomplete-item r34-tag-${item.type}" data-value="${item.value}" data-index="${i}">
<span class="r34-autocomplete-tag">${item.value}</span>
<span class="r34-autocomplete-count">${countDisplay}</span>
<span class="r34-autocomplete-type">${item.type}</span>
</div>`;
}).join("");
dropdown.style.display = "block";
},
hideAutocomplete() {
const dd = this.overlay?.querySelector(".r34-autocomplete-dropdown");
if (dd) dd.style.display = "none";
this.currentSuggestions = [];
this.selectedSuggestionIndex = -1;
},
handleKeyDown(e) {
const dd = this.overlay.querySelector(".r34-autocomplete-dropdown");
if (!dd || dd.style.display === "none") return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this.selectedSuggestionIndex = Math.min(this.selectedSuggestionIndex + 1, this.currentSuggestions.length - 1);
this.updateSelectedSuggestion(); break;
case "ArrowUp":
e.preventDefault();
this.selectedSuggestionIndex = Math.max(this.selectedSuggestionIndex - 1, -1);
this.updateSelectedSuggestion(); break;
case "Enter": case "Tab":
if (this.selectedSuggestionIndex >= 0) {
e.preventDefault();
this.selectSuggestion(this.currentSuggestions[this.selectedSuggestionIndex].value);
} break;
case "Escape": this.hideAutocomplete(); break;
}
},
updateSelectedSuggestion() {
this.overlay.querySelectorAll(".r34-autocomplete-item").forEach((el, i) => {
el.classList.toggle("selected", i === this.selectedSuggestionIndex);
});
},
selectSuggestion(tag) {
const ta = this.overlay.querySelector(".r34-search-input");
if (!ta) return;
const pos = ta.selectionStart, text = ta.value;
const before = text.substring(0, pos), after = text.substring(pos);
const bTags = before.split(/[\s\n]+/);
const start = before.lastIndexOf(bTags[bTags.length - 1]);
const aTags = after.split(/[\s\n]+/);
const end = pos + (aTags[0] ? aTags[0].length : 0);
const newText = text.substring(0, start) + tag + " " + text.substring(end);
ta.value = newText;
ta.setSelectionRange(start + tag.length + 1, start + tag.length + 1);
this.hideAutocomplete();
ta.focus();
},
getCurrentTag(text, pos) {
const before = text.substring(0, pos), after = text.substring(pos);
const bTags = before.split(/[\s\n]+/), aTags = after.split(/[\s\n]+/);
let tag = bTags[bTags.length - 1] || "";
if (aTags[0] && !text[pos]?.match(/[\s\n]/)) tag += aTags[0];
return tag.trim();
},
extractCount(label) { const m = label.match(/\((\d+)\)$/); return m ? parseInt(m[1]) : 0; },
addTriggerButton() {
const form = document.querySelector("div.tag-search");
if (!form || form.querySelector(".r34-search-overlay-trigger")) return;
const btn = Utils.createElement("button", {
className: "r34-search-overlay-trigger", type: "button",
innerHTML: "\u26A1", title: "Open Advanced Search (Press /)"
});
btn.addEventListener("click", e => { e.preventDefault(); this.open(); });
form.appendChild(btn);
},
populateCurrentSearch() {
const tags = new URLSearchParams(location.search).get("tags");
if (tags) {
const ta = this.overlay?.querySelector(".r34-search-input");
if (ta) ta.value = decodeURIComponent(tags);
}
},
open() {
if (!this.overlay || this.isOpen) return;
this.isOpen = true;
this.overlay.classList.add("show");
document.body.style.overflow = "hidden";
setTimeout(() => {
const ta = this.overlay.querySelector(".r34-search-input");
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
}, 100);
},
close() {
if (!this.overlay || !this.isOpen) return;
this.isOpen = false;
this.overlay.classList.remove("show");
document.body.style.overflow = "";
this.hideAutocomplete();
}
};
// ───────────────────────────────────────────────
// COLUMN CONTROL
// ───────────────────────────────────────────────
const ColumnControl = {
init() {
this.addControlPanel();
this.loadSavedColumns();
},
addControlPanel() {
if (document.getElementById("columnSlider")) return;
const panel = Utils.createElement("div", {
className: "r34-column-control",
innerHTML: `
<label for="columnSlider">Columns:</label>
<input type="range" id="columnSlider" min="1" max="8" value="3">
<span class="r34-column-count" id="columnCount">3</span>
`
});
const imageList = document.querySelector(".image-list");
if (imageList?.parentNode) imageList.parentNode.insertBefore(panel, imageList);
this.bindEvents();
},
bindEvents() {
const slider = document.getElementById("columnSlider");
const display = document.getElementById("columnCount");
if (!slider || !display) return;
slider.addEventListener("input", e => {
const v = e.target.value;
display.textContent = v;
this.setColumnCount(v);
GM_setValue("galleryColumns", parseInt(v));
});
},
setColumnCount(n) { document.documentElement.style.setProperty("--grid-columns", n); },
loadSavedColumns() {
const saved = GM_getValue("galleryColumns", 0);
const effective = saved || CFG.defaultColumns;
if (effective > 0) {
const slider = document.getElementById("columnSlider");
const display = document.getElementById("columnCount");
if (slider && display) {
slider.value = effective;
display.textContent = effective;
this.setColumnCount(effective);
}
}
}
};
// ───────────────────────────────────────────────
// IMAGE REPLACEMENT
// ───────────────────────────────────────────────
const ImageReplacement = {
processedImages: new Set(),
extCache: new Map(), // hash -> extension
retryQueue: new Map(), // src -> { img, retries, timer }
serverBase: "",
observer: null,
MAX_RETRIES: 3,
RETRY_DELAYS: [2000, 5000, 12000], // exponential-ish backoff
init() {
if (!CFG.imageReplacement) return;
this.serverBase = ImageServer.resolve();
Utils.log("Using image server:", this.serverBase);
// Block the browser from loading samples/previews – we only want thumbnails
// then upgrade to full images ourselves. This prevents double-loading.
this.blockSampleLoads();
// Use IntersectionObserver for proper lazy loading
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
this.replaceThumbnail(img);
// Keep observing – if it fails and we retry,
// we want to know when user scrolls back
}
});
},
{ rootMargin: "300px 0px" } // start loading 300px before visible
);
// Observe all current thumbnails
this.observeAll();
// Watch for dynamically added thumbnails
new MutationObserver(() => this.observeAll())
.observe(document.body, { childList: true, subtree: true });
},
/**
* Prevent the browser from loading sample-sized images.
* The site sometimes uses JS to swap thumbnail src to sample URLs.
* We intercept that by setting loading="lazy" on thumbnails early
* and letting our own replacement handle the upgrade.
*/
blockSampleLoads() {
document.querySelectorAll('img[src*="/thumbnails/"]').forEach(img => {
img.loading = "lazy";
img.decoding = "async";
// Store the original thumbnail src so we always have it
if (!img.dataset.thumbSrc) {
img.dataset.thumbSrc = img.src;
}
});
},
observeAll() {
document.querySelectorAll('img[src*="/thumbnails/"]').forEach(img => {
if (!img.dataset.observed) {
img.dataset.observed = "true";
img.loading = "lazy";
img.decoding = "async";
if (!img.dataset.thumbSrc) img.dataset.thumbSrc = img.src;
this.observer.observe(img);
}
});
},
extractHash(url) { const m = url.match(/thumbnail_([a-f0-9]+)/); return m ? m[1] : null; },
extractDirectory(url) { const m = url.match(/thumbnails\/+(\d+)\//); return m ? m[1] : null; },
extractID(url) { const m = url.match(/thumbnail_[a-f0-9]+\.\w+\?(\d+)/); return m ? m[1] : null; },
createBaseUrl(thumbUrl) {
const hash = this.extractHash(thumbUrl);
const dir = this.extractDirectory(thumbUrl);
if (!hash || !dir) return null;
return { base: `${this.serverBase}/${dir}/${hash}`, hash };
},
replaceThumbnail(img) {
const src = img.dataset.thumbSrc || img.src;
if (this.processedImages.has(src) || img.dataset.processing === "true" || img.dataset.replaced) return;
const data = this.createBaseUrl(src);
if (!data) return;
const { base, hash } = data;
this.processedImages.add(src);
img.dataset.processing = "true";
const id = this.extractID(src);
// Fast path: we already know the extension from a previous image
if (this.extCache.has(hash)) {
const ext = this.extCache.get(hash);
this.loadImage(img, `${base}.${ext}`, hash, ext, src, id);
return;
}
// Try extensions in order
this.tryExtensions(img, base, hash, src, id, 0);
},
tryExtensions(img, base, hash, thumbSrc, id, extIdx) {
const exts = ["jpg", "png", "jpeg", "gif"];
if (extIdx >= exts.length) {
// All extensions failed – schedule retry
Utils.log("All extensions failed for", hash, "– scheduling retry");
img.dataset.processing = "false";
this.processedImages.delete(thumbSrc);
this.scheduleRetry(img, thumbSrc);
return;
}
const ext = exts[extIdx];
const url = id ? `${base}.${ext}?${id}` : `${base}.${ext}`;
// Use a probe Image to avoid flickering the visible img on failure
const probe = new Image();
probe.onload = () => {
this.extCache.set(hash, ext);
img.src = url;
img.dataset.replaced = "loaded";
img.dataset.processing = "false";
// Cancel any pending retry
this.cancelRetry(thumbSrc);
};
probe.onerror = () => {
this.tryExtensions(img, base, hash, thumbSrc, id, extIdx + 1);
};
probe.src = url;
},
loadImage(img, url, hash, ext, thumbSrc, id) {
const fullUrl = id ? `${url}?${id}` : url;
const probe = new Image();
probe.onload = () => {
img.src = fullUrl;
img.dataset.replaced = "cached";
img.dataset.processing = "false";
this.cancelRetry(thumbSrc);
};
probe.onerror = () => {
// Cached extension failed – clear cache and retry from scratch
Utils.log("Cached extension failed for", hash, "– retrying");
this.extCache.delete(hash);
img.dataset.processing = "false";
this.processedImages.delete(thumbSrc);
this.scheduleRetry(img, thumbSrc);
};
probe.src = fullUrl;
},
scheduleRetry(img, thumbSrc) {
const existing = this.retryQueue.get(thumbSrc);
const retries = existing ? existing.retries : 0;
if (retries >= this.MAX_RETRIES) {
Utils.log("Max retries reached for", thumbSrc);
// Leave the thumbnail as-is
return;
}
const delay = this.RETRY_DELAYS[retries] || this.RETRY_DELAYS[this.RETRY_DELAYS.length - 1];
Utils.log(`Retry ${retries + 1}/${this.MAX_RETRIES} for`, thumbSrc, `in ${delay}ms`);
const timer = setTimeout(() => {
this.retryQueue.delete(thumbSrc);
// Only retry if still in/near viewport
const r = img.getBoundingClientRect();
if (r.top < window.innerHeight + 500 && r.bottom > -500) {
this.replaceThumbnail(img);
}
}, delay);
this.retryQueue.set(thumbSrc, { img, retries: retries + 1, timer });
},
cancelRetry(thumbSrc) {
const entry = this.retryQueue.get(thumbSrc);
if (entry) {
clearTimeout(entry.timer);
this.retryQueue.delete(thumbSrc);
}
},
processVisibleThumbnails() {
// Manual trigger – just observe everything, the IntersectionObserver handles the rest
this.observeAll();
}
};
// ───────────────────────────────────────────────
// COLLAPSIBLE SIDEBAR
// ───────────────────────────────────────────────
const CollapsibleSidebar = {
init() {
if (!CFG.sidebarCollapsible) return;
setTimeout(() => this.setup(), 100);
},
setup() {
const sidebar = document.getElementById("tag-sidebar");
if (!sidebar) return;
const saved = CFG.sidebarRememberState
? JSON.parse(GM_getValue("sidebarCollapsedStates", "{}"))
: {};
sidebar.querySelectorAll("h6").forEach(header => {
const name = header.textContent.toLowerCase().trim();
const section = document.createElement("div");
section.className = "tag-section";
let el = header.parentElement.nextElementSibling;
const items = [];
while (el && !el.querySelector("h6")) { items.push(el); el = el.nextElementSibling; }
items.forEach(item => section.appendChild(item));
header.parentElement.parentNode.insertBefore(section, header.parentElement.nextElementSibling);
if (saved[name]) {
header.classList.add("collapsed");
section.classList.add("collapsed");
}
header.addEventListener("click", e => {
e.preventDefault();
const collapsed = header.classList.contains("collapsed");
header.classList.toggle("collapsed");
section.classList.toggle("collapsed", !collapsed);
this.saveStates();
});
});
},
saveStates() {
if (!CFG.sidebarRememberState) return;
const states = {};
document.querySelectorAll("#tag-sidebar h6").forEach(h => {
states[h.textContent.toLowerCase().trim()] = h.classList.contains("collapsed");
});
GM_setValue("sidebarCollapsedStates", JSON.stringify(states));
}
};
// ───────────────────────────────────────────────
// MOBILE NAV
// ───────────────────────────────────────────────
const MobileNavigation = {
init() {
setTimeout(() => this.addMobileSearchLink(), 200);
},
addMobileSearchLink() {
const navbar = document.querySelector("#navbar");
if (!navbar || navbar.querySelector(".mobile-search-link")) return;
const li = Utils.createElement("li", { className: "mobile-search-item" });
const a = Utils.createElement("a", {
className: "mobile-search-link", href: "#",
innerHTML: "\u26A1 Search", title: "Open Enhanced Search"
});
a.addEventListener("click", e => { e.preventDefault(); SearchOverlay.open(); });
li.appendChild(a);
navbar.insertBefore(li, navbar.firstChild);
}
};
// ───────────────────────────────────────────────
// MAIN
// ───────────────────────────────────────────────
const MainApp = {
init() {
StylesManager.init();
if (CFG.defaultColumns > 0) {
document.documentElement.style.setProperty("--grid-columns", CFG.defaultColumns);
}
SearchOverlay.init();
ColumnControl.init();
ImageReplacement.init();
CollapsibleSidebar.init();
MobileNavigation.init();
window.processAllThumbnails = () => ImageReplacement.processVisibleThumbnails();
window.openSearchOverlay = () => SearchOverlay.open();
window.closeSearchOverlay = () => SearchOverlay.close();
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => MainApp.init());
} else {
MainApp.init();
}
})();