// ==UserScript==
// @name Rule34 Enhanced Dark Gallery
// @namespace ko-fi.com/awesome97076
// @version 3.0
// @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 none
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
debug: false,
searchOverlay: { enabled: true, hotkey: "/" },
imageReplacement: { enabled: true, batchDelay: 50 },
sidebar: { collapsible: true, rememberState: true },
};
const Utils = {
log: (...args) => CONFIG.debug && console.log("[Rule34 Enhanced]", ...args),
createElement: (tag, props = {}, children = []) => {
const element = document.createElement(tag);
Object.assign(element, props);
if (typeof children === "string") {
element.innerHTML = children;
} else {
children.forEach((child) => element.appendChild(child));
}
return element;
},
debounce: (func, wait) => {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
},
throttle: (func, limit) => {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
},
};
const StylesManager = {
init() {
const style = document.createElement("style");
style.innerHTML = 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;
--column-count: 3;
--column-gap: 3px;
--border-radius: 6px;
--box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--font-primary: 'Inter', 'Segoe UI', Roboto, sans-serif;
--font-secondary: 'Poppins', 'Segoe UI', Roboto, sans-serif;
--font-size-base: clamp(14px, 2.5vw, 16px);
}
html, body {
margin: 0;
padding: 0;
font-family: var(--font-primary);
font-size: var(--font-size-base);
background: var(--bg-color);
color: var(--text-primary);
min-width: 320px;
overflow-x: hidden;
}
*, *::before, *::after { box-sizing: border-box; }
div#content {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 8px;
}
div#header {
background: var(--bg-secondary);
padding: 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
position: sticky;
top: 0;
z-index: 1000;
margin-bottom: clamp(16px, 3vw, 24px);
}
div#header #site-title {
background-image: none;
padding: clamp(10px, 2vw, 15px) clamp(15px, 3vw, 20px);
}
div#header #site-title a {
color: var(--accent-color);
font-size: clamp(20px, 4vw, 24px);
font-weight: bold;
font-family: var(--font-secondary);
}
div#header ul#navbar,
div#header ul#subnavbar {
background: var(--bg-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
padding: 0 clamp(10px, 2vw, 20px);
margin: 0;
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
div#header ul#navbar li a,
div#header ul#subnavbar li a {
display: block;
padding: clamp(10px, 2vw, 15px);
color: var(--text-secondary);
transition: color 0.2s ease;
text-decoration: none;
white-space: nowrap;
font-size: clamp(13px, 2.5vw, 15px);
}
div#header ul#navbar li a:hover,
div#header ul#subnavbar li a:hover {
color: var(--accent-color);
}
a:link, a:visited {
color: var(--accent-color);
text-decoration: none;
transition: color 0.2s;
}
a:hover, a:active {
color: var(--accent-secondary);
text-decoration: underline;
}
.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; }
div.image-list {
display: block !important;
column-count: var(--column-count);
column-gap: var(--column-gap);
width: 100%;
margin: 0 auto;
padding: 0;
}
.thumb {
width: 100% !important;
height: auto !important;
display: inline-block !important;
break-inside: avoid;
margin-bottom: var(--column-gap);
padding: 0;
page-break-inside: avoid;
}
.thumb a {
display: block !important;
position: relative;
overflow: hidden;
border-radius: 8px;
background: var(--bg-tertiary);
box-shadow: var(--box-shadow);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.thumb a:hover {
transform: scale(1.08);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
z-index: 9999;
}
.thumb img, .thumb video {
width: 100% !important;
height: auto !important;
display: block !important;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
object-fit: contain;
}
.webm-thumb {
border: 2px solid #8e44ad !important;
border-radius: 8px !important;
}
.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;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.r34-search-overlay.show {
opacity: 1;
visibility: visible;
}
.r34-search-modal {
background: var(--bg-secondary);
border-radius: 16px;
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;
transform: scale(0.9) translateY(20px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
}
.r34-search-overlay.show .r34-search-modal {
transform: scale(1) translateY(0);
}
.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: 16px 16px 0 0;
}
.r34-search-title {
color: white;
font-family: var(--font-secondary);
font-size: 18px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.r34-search-title::before {
content: "⚡";
font-size: 20px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
.r34-search-controls {
display: flex;
gap: 8px;
}
.r34-search-minimize,
.r34-search-close {
background: rgba(255, 255, 255, 0.15);
color: white;
border: none;
border-radius: 6px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
font-weight: bold;
backdrop-filter: blur(10px);
}
.r34-search-minimize:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.r34-search-close:hover {
background: rgba(255, 85, 85, 0.8);
transform: scale(1.05);
}
.r34-search-body {
padding: 25px;
}
.r34-search-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.r34-search-input {
width: 100%;
padding: 15px 20px;
background: var(--bg-color);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-primary);
font-family: var(--font-primary);
font-size: 16px;
transition: all 0.3s ease;
resize: vertical;
min-height: 60px;
line-height: 1.4;
}
.r34-search-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(156, 100, 166, 0.15);
transform: translateY(-1px);
}
.r34-search-input::placeholder {
color: var(--text-muted);
font-style: italic;
}
.r34-search-button {
padding: 15px 25px;
background: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-secondary) 100%);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
transition: all 0.3s ease;
}
.r34-search-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(156, 100, 166, 0.4);
}
.r34-search-hint {
color: var(--text-muted);
font-size: 13px;
text-align: center;
margin-top: 10px;
font-style: italic;
}
.r34-search-shortcut {
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: monospace;
margin-left: 8px;
}
.r34-autocomplete-dropdown {
position: fixed;
background: var(--bg-color);
border: 2px solid var(--accent-color);
border-top: none;
border-radius: 0 0 12px 12px;
max-height: 300px;
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: 12px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
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: 14px;
font-weight: 500;
flex: 1;
margin-right: 8px;
}
.r34-autocomplete-count {
font-size: 12px;
color: var(--text-muted);
margin-right: 8px;
}
.r34-autocomplete-type {
font-size: 10px;
padding: 2px 6px;
border-radius: 12px;
text-transform: uppercase;
font-weight: bold;
min-width: 50px;
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;
}
.r34-column-control {
margin-bottom: 18px;
background: var(--bg-secondary);
padding: 15px;
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 15px;
box-shadow: var(--box-shadow);
}
.r34-column-control label {
color: var(--text-primary);
font-weight: 600;
min-width: 70px;
}
.r34-column-control input[type="range"] {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.r34-column-count {
color: var(--accent-color);
font-weight: bold;
font-size: 18px;
min-width: 20px;
text-align: center;
}
div.sidebar {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: var(--box-shadow);
padding: 0;
margin-right: clamp(10px, 2vw, 20px);
margin-bottom: clamp(15px, 3vw, 25px);
max-width: 280px;
min-width: 260px;
border: 1px solid var(--border-color);
overflow: hidden;
}
#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);
color: var(--text-primary);
margin: 0;
padding: 12px 20px;
font-family: var(--font-secondary);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: space-between;
}
#tag-sidebar h6:hover {
background: rgba(156, 100, 166, 0.2);
color: var(--accent-color);
}
#tag-sidebar h6::after {
content: '';
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid currentColor;
transition: transform 0.3s ease;
opacity: 0.7;
}
#tag-sidebar h6.collapsed::after {
transform: rotate(-90deg);
}
.tag-section {
overflow: hidden;
transition: max-height 0.4s ease, opacity 0.3s ease;
max-height: 1000px;
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);
transition: background 0.2s ease;
}
#tag-sidebar li:not(:has(h6)):hover {
background: rgba(255, 255, 255, 0.03);
}
div#footer {
margin-top: clamp(30px, 5vw, 40px);
padding: clamp(15px, 3vw, 20px) 0;
text-align: center;
color: var(--text-muted);
border-top: 1px solid var(--border-color);
font-size: clamp(12px, 2vw, 14px);
}
div.tag-search {
background: var(--bg-tertiary);
padding: clamp(10px, 2vw, 15px);
border-radius: var(--border-radius);
margin-bottom: clamp(15px, 3vw, 20px);
width: 100%;
position: relative;
}
div.tag-search input[type="text"] {
width: 100%;
padding: 8px 12px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
color: var(--text-primary);
font-family: var(--font-primary);
font-size: clamp(14px, 2.5vw, 16px);
margin-bottom: 10px;
}
div.tag-search input[type="submit"] {
padding: 8px 16px;
background: var(--accent-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: bold;
font-size: clamp(14px, 2.5vw, 16px);
transition: background 0.2s;
width: 100%;
max-width: 200px;
}
div.tag-search input[type="submit"]:hover {
background: var(--accent-secondary);
}
.r34-search-overlay-trigger {
position: absolute;
top: 10px;
right: 10px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s ease;
z-index: 10;
}
.r34-search-overlay-trigger:hover {
background: var(--accent-secondary);
transform: scale(1.1);
}
@media (min-width: 2560px) { :root { --column-count: 6; --column-gap: 20px; } }
@media (max-width: 2559px) and (min-width: 1920px) { :root { --column-count: 4; --column-gap: 18px; } }
@media (max-width: 1919px) and (min-width: 1600px) { :root { --column-count: 4; --column-gap: 16px; } }
@media (max-width: 1599px) and (min-width: 1200px) { :root { --column-count: 3; --column-gap: 14px; } }
@media (max-width: 1199px) and (min-width: 992px) { :root { --column-count: 3; --column-gap: 12px; } }
@media (max-width: 991px) and (min-width: 768px) { :root { --column-count: 3; --column-gap: 10px; } }
@media (max-width: 767px) and (min-width: 576px) { :root { --column-count: 2; --column-gap: 8px; } }
@media (max-width: 575px) {
:root { --column-count: 1; --column-gap: 10px; }
.r34-search-modal { width: 95%; margin: 10px; }
.r34-autocomplete-dropdown { max-height: 200px; }
.r34-autocomplete-item { padding: 10px 12px; }
}
`;
},
};
const SearchOverlay = {
overlay: null,
isOpen: false,
autocompleteCache: new Map(),
currentSuggestions: [],
selectedSuggestionIndex: -1,
pendingRequest: null,
lastQuery: "",
init() {
if (!CONFIG.searchOverlay.enabled) 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: • female solo animated • character_name -furry rating:safe • artist_name 1girl"
rows="6"
></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">↑↓</span> navigate
<span class="r34-search-shortcut">Tab/Enter</span> select
</div>
</form>
</div>
</div>
`,
});
document.body.appendChild(this.overlay);
},
bindEvents() {
if (!this.overlay) return;
const closeButton = this.overlay.querySelector(".r34-search-close");
const textarea = this.overlay.querySelector(".r34-search-input");
const dropdown = this.overlay.querySelector(".r34-autocomplete-dropdown");
closeButton?.addEventListener("click", () => this.close());
this.overlay.addEventListener("click", (e) => {
if (e.target === this.overlay) this.close();
});
document.addEventListener("keydown", (e) => {
const isInInput = document.activeElement?.tagName === "INPUT" ||
document.activeElement?.tagName === "TEXTAREA";
if (e.key === CONFIG.searchOverlay.hotkey && !this.isOpen && !isInInput) {
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), 400));
textarea.addEventListener("keydown", (e) => this.handleKeyDown(e));
textarea.addEventListener("blur", () => {
this.cancelPendingRequest();
setTimeout(() => this.hideAutocomplete(), 150);
});
this.populateCurrentSearch();
}
dropdown?.addEventListener("click", (e) => {
const suggestion = e.target.closest(".r34-autocomplete-item");
if (suggestion) this.selectSuggestion(suggestion.dataset.value);
});
},
async handleInput(e) {
const textarea = e.target;
const currentTag = this.getCurrentTag(textarea.value, textarea.selectionStart);
if (!currentTag || currentTag.length < 2 || currentTag === this.lastQuery) {
this.hideAutocomplete();
return;
}
this.lastQuery = currentTag;
this.cancelPendingRequest();
await this.showAutocomplete(currentTag);
},
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 requestPromise = fetch(`https://ac.rule34.xxx/autocomplete.php?q=${encodeURIComponent(query)}`);
this.pendingRequest = { promise: requestPromise, cancelled: false };
const response = await requestPromise;
if (this.pendingRequest?.cancelled || !response.ok) return [];
const jsonData = await response.json();
if (this.pendingRequest?.cancelled) return [];
const suggestions = jsonData.slice(0, 8).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 > 50) this.cleanupCache();
this.pendingRequest = null;
return suggestions;
} catch (error) {
this.pendingRequest = null;
return [];
}
},
cleanupCache() {
const cacheEntries = Array.from(this.autocompleteCache.entries()).slice(0, 30);
this.autocompleteCache.clear();
cacheEntries.forEach(([key, value]) => this.autocompleteCache.set(key, value));
},
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 === 0) {
this.hideAutocomplete();
return;
}
// Position dropdown with your preferred coordinates
dropdown.style.left = "27px";
dropdown.style.width = "545px";
this.currentSuggestions = suggestions;
this.selectedSuggestionIndex = -1;
dropdown.innerHTML = suggestions
.map((item, index) => {
const typeClass = `r34-tag-${item.type}`;
const countDisplay = item.count > 0 ? ` (${item.count})` : "";
return `
<div class="r34-autocomplete-item ${typeClass}" data-value="${item.value}" data-index="${index}">
<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 dropdown = this.overlay.querySelector(".r34-autocomplete-dropdown");
if (dropdown) dropdown.style.display = "none";
this.currentSuggestions = [];
this.selectedSuggestionIndex = -1;
},
handleKeyDown(e) {
const dropdown = this.overlay.querySelector(".r34-autocomplete-dropdown");
const isDropdownVisible = dropdown && dropdown.style.display !== "none";
if (!isDropdownVisible) 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();
const selectedTag = this.currentSuggestions[this.selectedSuggestionIndex].value;
this.selectSuggestion(selectedTag);
}
break;
case "Escape":
this.hideAutocomplete();
break;
}
},
updateSelectedSuggestion() {
const items = this.overlay.querySelectorAll(".r34-autocomplete-item");
items.forEach((item, index) => {
item.classList.toggle("selected", index === this.selectedSuggestionIndex);
});
},
selectSuggestion(selectedTag) {
const textarea = this.overlay.querySelector(".r34-search-input");
if (!textarea) return;
const cursorPosition = textarea.selectionStart;
const text = textarea.value;
const beforeCursor = text.substring(0, cursorPosition);
const afterCursor = text.substring(cursorPosition);
const beforeTags = beforeCursor.split(/[\s\n]+/);
const currentTagStart = beforeCursor.lastIndexOf(beforeTags[beforeTags.length - 1]);
const afterTags = afterCursor.split(/[\s\n]+/);
const currentTagEnd = cursorPosition + (afterTags[0] ? afterTags[0].length : 0);
const newText = text.substring(0, currentTagStart) + selectedTag + " " + text.substring(currentTagEnd);
const newCursorPosition = currentTagStart + selectedTag.length + 1;
textarea.value = newText;
textarea.setSelectionRange(newCursorPosition, newCursorPosition);
this.hideAutocomplete();
textarea.focus();
},
getCurrentTag(text, cursorPosition) {
const beforeCursor = text.substring(0, cursorPosition);
const afterCursor = text.substring(cursorPosition);
const beforeTags = beforeCursor.split(/[\s\n]+/);
const afterTags = afterCursor.split(/[\s\n]+/);
let currentTag = beforeTags[beforeTags.length - 1] || "";
if (afterTags[0] && !text[cursorPosition]?.match(/[\s\n]/)) {
currentTag += afterTags[0];
}
return currentTag.trim();
},
extractCount(label) {
const match = label.match(/\((\d+)\)$/);
return match ? parseInt(match[1]) : 0;
},
addTriggerButton() {
const searchForm = document.querySelector("div.tag-search");
if (!searchForm || searchForm.querySelector(".r34-search-overlay-trigger")) return;
const triggerButton = Utils.createElement("button", {
className: "r34-search-overlay-trigger",
type: "button",
innerHTML: "⚡",
title: "Open Advanced Search (Press /)",
});
triggerButton.addEventListener("click", (e) => {
e.preventDefault();
this.open();
});
searchForm.appendChild(triggerButton);
},
populateCurrentSearch() {
const urlParams = new URLSearchParams(window.location.search);
const tags = urlParams.get("tags");
if (tags) {
const textarea = this.overlay?.querySelector(".r34-search-input");
if (textarea) textarea.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 textarea = this.overlay.querySelector(".r34-search-input");
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
}, 150);
},
close() {
if (!this.overlay || !this.isOpen) return;
this.isOpen = false;
this.overlay.classList.remove("show");
document.body.style.overflow = "";
this.hideAutocomplete();
},
};
const ColumnControl = {
init() {
this.addControlPanel();
this.loadSavedColumns();
},
addControlPanel() {
if (document.getElementById("columnSlider")) return;
const controlPanel = 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(controlPanel, imageList);
}
this.bindEvents();
},
bindEvents() {
const slider = document.getElementById("columnSlider");
const countDisplay = document.getElementById("columnCount");
if (!slider || !countDisplay) return;
slider.addEventListener("input", (e) => {
const count = e.target.value;
countDisplay.textContent = count;
this.setColumnCount(count);
localStorage.setItem("galleryColumns", count);
});
},
setColumnCount(count) {
document.documentElement.style.setProperty("--column-count", count);
},
loadSavedColumns() {
const savedColumns = localStorage.getItem("galleryColumns");
if (savedColumns) {
const slider = document.getElementById("columnSlider");
const countDisplay = document.getElementById("columnCount");
if (slider && countDisplay) {
slider.value = savedColumns;
countDisplay.textContent = savedColumns;
this.setColumnCount(savedColumns);
}
}
},
};
const ImageReplacement = {
processedImages: new Set(),
init() {
if (!CONFIG.imageReplacement.enabled) return;
this.bindEvents();
setTimeout(() => this.processAllThumbnails(), 500);
},
bindEvents() {
const scrollHandler = Utils.throttle(() => this.processVisibleThumbnails(), 200);
window.addEventListener("scroll", scrollHandler);
const observer = new MutationObserver((mutations) => {
let hasNewThumbnails = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName === "IMG" && node.src.includes("/thumbnails/")) {
hasNewThumbnails = true;
} else if (node.querySelector?.('img[src*="/thumbnails/"]')) {
hasNewThumbnails = true;
}
}
});
});
if (hasNewThumbnails) {
setTimeout(() => this.processVisibleThumbnails(), 200);
}
});
observer.observe(document.body, { childList: true, subtree: true });
},
extractHashFromThumbnail(thumbnailUrl) {
const match = thumbnailUrl.match(/thumbnail_([a-f0-9]+)/);
return match ? match[1] : null;
},
extractDirectoryFromThumbnail(thumbnailUrl) {
const match = thumbnailUrl.match(/thumbnails\/(\d+)\//);
return match ? match[1] : null;
},
createImageUrl(thumbnailUrl, extension) {
const hash = this.extractHashFromThumbnail(thumbnailUrl);
const directory = this.extractDirectoryFromThumbnail(thumbnailUrl);
if (!hash || !directory) return null;
return `https://rule34.xxx/images/${directory}/${hash}.${extension}`;
},
async testImageUrl(url) {
return new Promise((resolve) => {
const testImg = new Image();
testImg.onload = () => resolve(true);
testImg.onerror = () => resolve(false);
testImg.src = url;
});
},
async replaceThumbnailWithSample(img) {
const originalSrc = img.src;
if (this.processedImages.has(originalSrc) || img.dataset.replaced || !originalSrc.includes("/thumbnails/")) {
return;
}
this.processedImages.add(originalSrc);
img.dataset.processing = "true";
const extensions = ["jpg", "jpeg", "png", "gif"];
for (const ext of extensions) {
const sampleUrl = this.createImageUrl(originalSrc, ext);
if (!sampleUrl) continue;
const success = await this.testImageUrl(sampleUrl);
if (success) {
img.src = sampleUrl;
img.dataset.replaced = "sample";
img.dataset.processing = "false";
break;
}
}
},
processAllThumbnails() {
const thumbnails = Array.from(document.querySelectorAll('img[src*="/thumbnails/"]'));
thumbnails.forEach((img, index) => {
setTimeout(() => {
this.replaceThumbnailWithSample(img);
}, index * CONFIG.imageReplacement.batchDelay);
});
},
processVisibleThumbnails() {
const thumbnails = Array.from(document.querySelectorAll('img[src*="/thumbnails/"]')).filter((img) => {
if (img.dataset.replaced || img.dataset.processing) return false;
const rect = img.getBoundingClientRect();
return rect.top < window.innerHeight + 300 && rect.bottom > -300;
});
thumbnails.forEach((img) => this.replaceThumbnailWithSample(img));
},
};
const CollapsibleSidebar = {
init() {
if (!CONFIG.sidebar.collapsible) return;
setTimeout(() => this.initializeCollapsibleSidebar(), 100);
},
initializeCollapsibleSidebar() {
const tagSidebar = document.getElementById("tag-sidebar");
if (!tagSidebar) return;
const savedStates = CONFIG.sidebar.rememberState
? JSON.parse(localStorage.getItem("sidebarCollapsedStates") || "{}")
: {};
const headers = tagSidebar.querySelectorAll("h6");
headers.forEach((header) => {
const categoryName = header.textContent.toLowerCase().trim();
const tagSection = document.createElement("div");
tagSection.className = "tag-section";
let currentElement = header.parentElement.nextElementSibling;
const tagItems = [];
while (currentElement && !currentElement.querySelector("h6")) {
tagItems.push(currentElement);
currentElement = currentElement.nextElementSibling;
}
tagItems.forEach((item) => tagSection.appendChild(item));
header.parentElement.parentNode.insertBefore(tagSection, header.parentElement.nextElementSibling);
if (savedStates[categoryName]) {
header.classList.add("collapsed");
tagSection.classList.add("collapsed");
}
this.bindHeaderEvents(header, tagSection, categoryName);
});
},
bindHeaderEvents(header, tagSection, categoryName) {
const toggleSection = () => {
const isCollapsed = header.classList.contains("collapsed");
header.classList.toggle("collapsed");
if (isCollapsed) {
tagSection.classList.remove("collapsed");
} else {
tagSection.classList.add("collapsed");
}
this.saveCollapsedStates();
};
header.addEventListener("click", (e) => {
e.preventDefault();
toggleSection();
});
},
saveCollapsedStates() {
if (!CONFIG.sidebar.rememberState) return;
const states = {};
const headers = document.querySelectorAll("#tag-sidebar h6");
headers.forEach((header) => {
const categoryName = header.textContent.toLowerCase().trim();
states[categoryName] = header.classList.contains("collapsed");
});
localStorage.setItem("sidebarCollapsedStates", JSON.stringify(states));
},
};
const MainApp = {
init() {
setTimeout(() => {
StylesManager.init();
SearchOverlay.init();
ColumnControl.init();
ImageReplacement.init();
CollapsibleSidebar.init();
this.exposeGlobalFunctions();
}, 200);
},
exposeGlobalFunctions() {
window.processAllThumbnails = () => ImageReplacement.processAllThumbnails();
window.openSearchOverlay = () => SearchOverlay.open();
window.closeSearchOverlay = () => SearchOverlay.close();
},
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => MainApp.init());
} else {
MainApp.init();
}
})();