Sleazy Fork is available in English.
Preloads and dynamically displays preview images below links. Implements fallback to the next image if the first fails to load. Includes debug mode.
// ==UserScript==
// @name Pornolab Preloaded Preview
// @version 1.7.0
// @description Preloads and dynamically displays preview images below links. Implements fallback to the next image if the first fails to load. Includes debug mode.
// @author Ace
// @license MIT
// @match *://pornolab.net/forum/tracker*
// @match *://pornolab.net/forum/viewforum*
// @match *://pornolab.net/forum/search*
// @icon https://static.pornolab.net/favicon.ico
// @run-at document-end
// @grant none
// @namespace https://greasyfork.org/users/1418199
// ==/UserScript==
(function () {
"use strict";
/* ================= CONFIG ================= */
const DEBUG_MODE = false; // Set to true to see detailed logs in console (F12)
const MIN_HEIGHT = 51;
const MAX_CONCURRENT_FETCHES = 4;
const PREVIEW_HEIGHT = "21rem";
const LAZY_LOAD_MARGIN = "500px";
/* ================= LOGGER ================= */
function log(...args) {
if (DEBUG_MODE) {
console.log("[Pornolab Preview]", ...args);
}
}
/* ================= STYLES ================= */
const style = document.createElement("style");
style.textContent = `
.preview-container {
position: relative;
width: 100%;
max-width: 100%;
height: ${PREVIEW_HEIGHT};
display: flex; /* Reverted to flex for perfect centering */
justify-content: center;
align-items: center;
overflow: hidden;
margin-top: 0.5rem;
background: #111;
border-radius: 6px;
font-size: 0.9rem;
color: #888;
text-decoration: none !important;
cursor: pointer;
isolation: isolate;
box-sizing: border-box;
transition: all 0.2s ease;
}
.preview-container:hover {
box-shadow: 0 0 12px rgba(255, 255, 255, 0.15);
}
/* --- OWNED TOPIC STYLES --- */
.preview-container.owned-topic {
border: 3px solid #4CAF50;
box-shadow: 0 0 15px rgba(76, 175, 80, 0.25);
}
.preview-container.owned-topic:hover {
box-shadow: 0 0 20px rgba(76, 175, 80, 0.5);
}
.owned-badge {
position: absolute;
top: 8px;
right: 8px;
background: #4CAF50;
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
z-index: 10;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
pointer-events: none;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* --- CAROUSEL NAVIGATION --- */
.nav-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
font-size: 36px;
cursor: pointer;
padding: 20px 15px;
z-index: 10;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
border-radius: 4px;
user-select: none;
}
.preview-container:hover .nav-arrow { opacity: 1; }
.nav-arrow:hover { background: rgba(0, 0, 0, 0.9); }
.nav-prev { left: 10px; }
.nav-next { right: 10px; }
.img-counter {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
z-index: 10;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.preview-container:hover .img-counter { opacity: 1; }
/* TEXT PLACEHOLDER FOR NO IMAGES */
.preview-container .no-img-text {
position: relative;
}
/* THE BLURRED BACKGROUND */
.preview-container img.bg-blur {
position: absolute;
top: -10%; left: -10%;
width: 120%; height: 120%;
object-fit: cover;
filter: blur(15px);
opacity: 0.4;
z-index: -1;
pointer-events: none;
}
/* THE MAIN FOREGROUND IMAGE */
.preview-container img.main-img {
position: relative;
max-width: 100%;
max-height: 100%;
object-fit: contain;
z-index: 1;
border-radius: 4px;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
`;
document.head.appendChild(style);
/* ================= INDEXED DB LOGIC ================= */
let dbCache = null;
async function getDb() {
if (dbCache) return dbCache;
return new Promise((resolve, reject) => {
const request = indexedDB.open("pornolab_torrents");
request.onsuccess = (event) => {
dbCache = event.target.result;
resolve(dbCache);
};
request.onerror = (event) => reject(event.target.error);
});
}
async function checkTopicInDb(topicId) {
if (!topicId || isNaN(topicId)) return false;
try {
const db = await getDb();
if (!db.objectStoreNames.contains("torrent_ids")) return false;
return new Promise((resolve) => {
const transaction = db.transaction(["torrent_ids"], "readonly");
const store = transaction.objectStore("torrent_ids");
const reqNum = store.get(topicId);
reqNum.onsuccess = (event) => {
if (event.target.result !== undefined) {
resolve(true);
} else {
const reqStr = store.get(topicId.toString());
reqStr.onsuccess = (e) => resolve(e.target.result !== undefined);
reqStr.onerror = () => resolve(false);
}
};
reqNum.onerror = () => resolve(false);
});
} catch (e) {
return false;
}
}
function extractTopicId(url) {
try {
const urlObj = new URL(url);
const tParam = urlObj.searchParams.get("t");
return tParam ? parseInt(tParam, 10) : null;
} catch (e) {
return null;
}
}
/* ================= CACHE & NETWORK ================= */
const previewCache = new Map();
function buildUrl(link) {
return new URL(link.getAttribute("href"), location.href).href;
}
function fetchPreviewUrls(url) {
if (!previewCache.has(url)) {
log(`[Network] Fetching HTML for: ${url}`);
const fetchPromise = (async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
// REGEX PARSING
const urls = [];
const tagRegex = /<[^>]+class=(["'])[^>]*\bpostImg\b[^>]*\1[^>]*>/gi;
let match;
while ((match = tagRegex.exec(html)) !== null) {
const titleMatch = match[0].match(/title=(["'])(.*?)\1/i);
if (titleMatch && titleMatch[2]) {
const extractedUrl = titleMatch[2].replace(/&/g, '&');
// Prevent pushing JS regex templates like "$1" found in <script> blocks
if (!extractedUrl.startsWith("$")) {
urls.push(extractedUrl);
}
}
}
log(`[Network] Successfully parsed ${urls.length} image URLs for ${url}`);
return urls;
})().catch(err => {
log(`[Network] Fetch failed for ${url}`, err);
return [];
});
previewCache.set(url, fetchPromise);
}
return previewCache.get(url);
}
/* ================= DOM INJECTION & CAROUSEL ================= */
function insertPreview(link, urls, isOwned, topicId) {
const container = document.createElement("a");
container.className = "preview-container";
container.href = link.href;
container.target = "_blank";
container.rel = "noopener noreferrer";
if (isOwned) {
container.classList.add("owned-topic");
const badge = document.createElement("div");
badge.className = "owned-badge";
badge.textContent = "OWNED";
container.appendChild(badge);
}
if (!urls || urls.length === 0) {
const span = document.createElement("span");
span.className = "no-img-text";
span.textContent = "No Eligible preview found";
container.appendChild(span);
link.after(container);
return;
}
let validUrls = [...urls];
let currentIndex = 0;
const bgImg = document.createElement("img");
bgImg.className = "bg-blur";
//bgImg.loading = "lazy";
const mainImg = document.createElement("img");
mainImg.className = "main-img";
//mainImg.loading = "lazy";
const counter = document.createElement("div");
counter.className = "img-counter";
const updateView = (index) => {
if (validUrls.length === 0) return;
currentIndex = (index + validUrls.length) % validUrls.length;
bgImg.src = validUrls[currentIndex];
mainImg.src = validUrls[currentIndex];
if (validUrls.length > 1) {
counter.textContent = `${currentIndex + 1} / ${validUrls.length}`;
counter.style.display = "block";
} else {
counter.style.display = "none";
}
};
const prevBtn = document.createElement("button");
prevBtn.className = "nav-arrow nav-prev";
prevBtn.innerHTML = "❮";
const nextBtn = document.createElement("button");
nextBtn.className = "nav-arrow nav-next";
nextBtn.innerHTML = "❯";
const handleNavClick = (e, direction) => {
e.preventDefault();
e.stopPropagation();
updateView(currentIndex + direction);
};
prevBtn.onclick = (e) => handleNavClick(e, -1);
nextBtn.onclick = (e) => handleNavClick(e, 1);
const handleImageError = () => {
validUrls.splice(currentIndex, 1);
if (validUrls.length > 0) {
updateView(currentIndex >= validUrls.length ? 0 : currentIndex);
} else {
const span = document.createElement("span");
span.className = "no-img-text";
span.textContent = "No Eligible preview found";
mainImg.replaceWith(span);
bgImg.remove();
prevBtn.remove();
nextBtn.remove();
counter.remove();
}
if (validUrls.length <= 1) {
prevBtn.style.display = 'none';
nextBtn.style.display = 'none';
}
};
const handleImageLoad = () => {
if (mainImg.naturalHeight < MIN_HEIGHT) handleImageError();
};
mainImg.addEventListener("error", handleImageError);
mainImg.addEventListener("load", handleImageLoad);
container.appendChild(bgImg);
container.appendChild(mainImg);
container.appendChild(counter);
if (urls.length > 1) {
container.appendChild(prevBtn);
container.appendChild(nextBtn);
}
updateView(0);
link.after(container);
}
/* ================= LAZY QUEUE PROCESSING ================= */
const queue = [];
let activeFetches = 0;
async function processQueue() {
if (activeFetches >= MAX_CONCURRENT_FETCHES || queue.length === 0) return;
const link = queue.shift();
activeFetches++;
const url = buildUrl(link);
const topicId = extractTopicId(url);
try {
const [urls, isOwned] = await Promise.all([
fetchPreviewUrls(url),
checkTopicInDb(topicId)
]);
insertPreview(link, urls, isOwned, topicId);
} catch (err) {
const isOwned = await checkTopicInDb(topicId);
insertPreview(link, [], isOwned, topicId);
} finally {
activeFetches--;
processQueue();
}
}
/* ================= INTERSECTION OBSERVER ================= */
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const link = entry.target;
obs.unobserve(link);
queue.push(link);
processQueue();
}
});
}, { rootMargin: LAZY_LOAD_MARGIN });
const links = document.querySelectorAll(".tLink, .tt-text");
links.forEach(link => observer.observe(link));
})();