Добавляет режим отображения миниатюр на форуме Pornolab
// ==UserScript==
// @name Pornolab: Thumbnail Viewing Mode
// @namespace https://pornolab.net
// @version 1.2.4
// @description Добавляет режим отображения миниатюр на форуме Pornolab
// @author UserScript
// @match https://pornolab.net/forum/viewforum.php*
// @match https://www.pornolab.net/forum/viewforum.php*
// @run-at document-end
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ── Разрешённые форумы ────────────────────────────────────────────────────
const ALLOWED_FORUMS = [1750, 1756, 1869, 1785, 1790, 1827, 1870, 1828];
const SEARCH_FORUMS = '1750,1756,1869,1785,1790,1827,1870,1828,1838,1829,1865';
const urlParams = new URLSearchParams(window.location.search);
const forumId = parseInt(urlParams.get('f'), 10);
if (!ALLOWED_FORUMS.includes(forumId)) return;
// ── Заблокированные картинки (Set, без протокола) ─────────────────────────
const BANNED_IMG_URLS = new Set([
'//i2.fastpic.org/big/2010/0310/32/854fb7965392732c91cb3e756524d332.png',
'//i2.fastpic.org/big/2010/0310/14/7c0cf414236cdd520b33218c1cedc514.png',
'//i36.fastpic.org/big/2012/0525/a9/466c2662cb717ef1c0efd00032143ca9.png',
'//i35.fastpic.org/big/2012/0503/80/dd7d759ebfd78d71492d083b572e4e80.png',
'//i1.fastpic.org/big/2010/0102/40/c5938e4e653dff99fccac7d78cae9f40.png',
'//i1.fastpic.org/big/2010/0102/49/56073ee6dafb650fea11d91287a08e49.png',
'//i1.fastpic.org/big/2010/0102/62/72f5ea523bdf11d2776caf30b0cd7562.png',
'//i1.fastpic.org/big/2010/0109/4f/e305c1f3e72722cd5f25fec289b67b4f.png',
'//i1.fastpic.org/big/2010/0102/1e/a3c9cba63861b2e09d00533acd5c8c1e.png',
'//i28.fastpic.org/big/2012/0130/47/e8a566cfcef42519ba185514aa82ee47.jpg',
'//i32.fastpic.org/big/2011/1019/ac/2393f14a0c2ef2056979b0ea0ffb09ac.png',
'//i30.fastpic.org/big/2012/0309/f3/09b9d53be75c9a83a33159a540d0a2f3.png',
'//i1.fastpic.org/big/2009/1124/91/e618bd262f9f2251a2edeb0645237291.png',
'//i1.fastpic.org/big/2009/1124/2a/1e377fa14b2ea64f94b82b9912d5132a.png',
'//i1.fastpic.org/big/2009/1124/06/7abcefdda614dd7aa6155500dc880c06.png',
'//i1.fastpic.org/big/2009/1121/82/3da8ab31a888353fdb47281456a60382.png',
'//i1.fastpic.org/big/2009/1121/af/074c5778b58884b9b913d60271b134af.png',
'//i1.fastpic.org/big/2009/1121/39/b7700efec1a7a881b231dde287f3dd39.png',
'//i29.fastpic.org/big/2011/0911/f0/4548a942ada5cece195498a60c55a1f0.gif',
'//i1.fastpic.org/big/2010/0106/f1/f9d8869c3afefc454c97fef5241ebef1.jpg',
'//i36.fastpic.org/big/2012/0610/cb/4764e4687d5114780a5b56f11715d6cb.png',
'//i27.fastpic.org/big/2012/0212/6a/7af175481cfb123daa4c9263dba9656a.png',
'//i40.fastpic.org/big/2012/0824/bd/61c490902a568edf4683b8ef78c66ebd.png',
'//i27.fastpic.org/big/2012/0212/85/06760d1d5971f07a9773ea4e44bf8d85.png',
'//i31.fastpic.org/big/2012/0206/30/dfc0ab4b267446571dc9f42564060d30.png',
'//i29.fastpic.org/big/2012/0309/be/f1bc6f9805a8f7c6f8034f029b17febe.png',
'//i28.fastpic.org/big/2012/0309/cd/dfbf2ab87548d62463d004b39cd031cd.png',
]);
const isBanned = url => BANNED_IMG_URLS.has(url.replace(/^https?:/, ''));
// ── Настройки по умолчанию ────────────────────────────────────────────────
const DEFAULTS = {
cardWidth: 230,
imgMaxHeight: 250,
imgMaxWidth: 220,
columns: 'auto',
gap: 4,
showJapanese: true,
showRussian: true,
showEnglish: true,
showProducer: true,
showCensorship: true,
showYear: true,
showTags: true,
showSeeders: true,
showTorrentSize: true,
showDownloads: true,
showFlags: true,
showVersion: true,
tagsMaxVisible: 15,
headFontSize: 11,
metaFontSize: 11,
statsFontSize: 11,
tagFontSize: 10,
tagsGap: 3,
cacheTTL: 48,
concurrency: 6,
colors: {
cardBg: '#efefef',
cardBorder: '#c0c0c0',
titleColor: '#1a3a6a',
producerColor: '#345da4',
yearColor: '#000000',
censorColor: '#000000',
tagBg: '#dde0e8',
tagColor: '#334466',
tagBorder: '#b0b8cc',
statSizeColor: '#0055aa',
dlCountColor: '#000000',
metaTextColor: '#555555',
sepColor: '#000000',
moreColor: '#102175',
},
};
let settings = { ...DEFAULTS, colors: { ...DEFAULTS.colors } };
let thumbMode = false;
let imgCache = {};
let topicData = new Map(); // topicId → parsedData
// ── LocalStorage helpers ──────────────────────────────────────────────────
function lsGet(key, fallback) {
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback; }
catch { return fallback; }
}
function lsSet(key, val) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
}
function loadAll() {
const saved = lsGet('plThumb_settings', {});
settings = {
...DEFAULTS,
...saved,
colors: { ...DEFAULTS.colors, ...(saved.colors || {}) },
};
thumbMode = lsGet('plThumb_mode', false);
const raw = lsGet('plThumb_imgCache', {});
const now = Date.now();
imgCache = {};
for (const [k, v] of Object.entries(raw)) {
if (now - v.ts < settings.cacheTTL * 3_600_000) imgCache[k] = v;
}
}
function saveSettings() { lsSet('plThumb_settings', settings); }
function saveCache() { lsSet('plThumb_imgCache', imgCache); }
// ── Парсинг заголовка ─────────────────────────────────────────────────────
function stripHtml(s) {
return s
.replace(/<wbr\s*\/?>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&(amp|lt|gt|nbsp|#(\d+));/g, (_, e, n) =>
e === 'amp' ? '&' : e === 'lt' ? '<' : e === 'gt' ? '>' :
e === 'nbsp' ? ' ' : String.fromCodePoint(+n))
.trim();
}
function popBracket(text, open, close) {
const idx = text.lastIndexOf(close);
if (idx < 0) return null;
let depth = 0, i = idx;
while (i >= 0) {
if (text[i] === close) depth++;
else if (text[i] === open) { depth--; if (depth === 0) break; }
i--;
}
if (i < 0) return null;
return { inner: text.slice(i + 1, idx).trim(), rest: (text.slice(0, i) + ' ' + text.slice(idx + 1)).trim() };
}
function parseTitle(raw) {
let text = stripHtml(raw);
let lang = '', year = '', tags = [], censorship = '', producer = '', version = '';
// Отбрасываем мусорные (...) в хвосте, стоящие после последнего [...] (устаревший формат)
text = text.replace(/(\])\s*(?:\([^)]*\)\s*)+$/, '$1');
const OS_RE = /\b(win(?:dows)?|lin(?:ux)?|mac(?:os)?|andr(?:oid)?|re(?:pack)?|ren(?:\'py)?|apk)\b/i;
let b = popBracket(text, '[', ']');
while (b && OS_RE.test(b.inner)) {
text = b.rest; // выбрасываем тег ОС как мусорный
b = popBracket(text, '[', ']');
}
// Нормализуем кириллические омоглифы перед сравнением (е→e, а→a, р→r, с→c, у→u)
// Сами lang/text сохраняем оригинальными — только матчинг исправляем
const normForLang = s => s
.replace(/[еЕ]/g, 'e').replace(/[аА]/g, 'a').replace(/[рР]/g, 'r')
.replace(/[сС]/g, 'c').replace(/[уУ]/g, 'u').replace(/[оО]/g, 'o');
if (b && /\b(rus|eng|jap|chi|multi)\b/i.test(normForLang(b.inner))) {
lang = b.inner; text = b.rest;
// Починка «забытой» закрывающей скобки перед языком:
// "[2016, ADV, Magic, Yuri [jap]" → "[2016, ADV, Magic, Yuri] [jap]"
let depth = 0;
for (const ch of text) {
if (ch === '[') depth++;
else if (ch === ']') depth--;
}
if (depth > 0) text += ']'.repeat(depth);
}
// Выбрасываем отдельный [...] содержащий только названия ОС (устаревший формат)
const OS_ONLY_RE = /^(?:win(?:dows?)?|andr(?:oid)?|lin(?:ux|ix)?|mac(?:os)?)(?:[\s,\/+]+(?:win(?:dows?)?|andr(?:oid)?|lin(?:ux|ix)?|mac(?:os)?))*$/i;
let osOnly = popBracket(text, '[', ']');
while (osOnly && OS_ONLY_RE.test(osOnly.inner.trim())) {
text = osOnly.rest;
osOnly = popBracket(text, '[', ']');
}
b = popBracket(text, '[', ']');
if (b) {
const parts = b.inner.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length && /^\d{4}/.test(parts[0])) {
year = parts[0].replace(/\s*г\.\s*$/, '').replace(/\s*[-–/]\s*/g, '–').trim();
tags = parts.slice(1);
} else {
tags = parts;
}
text = b.rest;
}
b = popBracket(text, '[', ']');
if (b && /^(pt)?(un)?cen(sored)?$/i.test(b.inner.trim())) {
censorship = b.inner.trim(); text = b.rest;
} else if (b) {
text = b.rest + ' [' + b.inner + ']';
}
b = popBracket(text, '(', ')');
if (b) { producer = b.inner; text = b.rest; }
b = popBracket(text, '[', ']');
if (b) { version = b.inner; text = b.rest; }
return { title: text.trim(), version, producer, censorship, year, tags, lang };
}
// ── HTML-escaping ─────────────────────────────────────────────────────────
const E = s => String(s ?? '')
.replace(/&/g,'&').replace(/</g,'<')
.replace(/>/g,'>').replace(/"/g,'"');
// ── Поисковая ссылка ──────────────────────────────────────────────────────
function searchUrl(q) {
return `https://pornolab.net/forum/tracker.php?nm=${encodeURIComponent('"' + q + '"')}&f=${SEARCH_FORUMS}`;
}
// ── Получение thumbnail из темы ───────────────────────────────────────────
const IMG_RE_SRC = String.raw`<var[^>]+class="postImg[^"]*"[^>]+title="(https?:\/\/[^"]*(?:fastpic\.org|imgbox\.com|imagebam\.com|imageban\.ru)[^"]+)"`;
function scanImgs(html) {
const re = new RegExp(IMG_RE_SRC, 'gi');
const out = [];
let m;
while ((m = re.exec(html)) !== null) {
if (!isBanned(m[1])) out.push(m[1]);
}
return out;
}
async function fetchThumb(topicId, topicUrl) {
if (imgCache[topicId] !== undefined) return imgCache[topicId].url;
try {
const resp = await fetch(topicUrl, { credentials: 'include' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const text = await resp.text();
const allTds = [...text.matchAll(/<td[^>]+class="[^"]*message[^"]*"/gi)];
const firstPostHtml = text.slice(0, allTds.length >= 2 ? allTds[1].index : text.length);
let matches = scanImgs(firstPostHtml);
if (!matches.length) matches = scanImgs(text);
const url = matches[0] || '';
imgCache[topicId] = { url, ts: Date.now() };
saveCache();
return url;
} catch {
imgCache[topicId] = { url: '', ts: Date.now() };
return '';
}
}
// ── Парсинг строки темы ───────────────────────────────────────────────────
function parseTr(tr) {
const topicId = tr.id.slice(3);
const titleEl = tr.querySelector('a.torTopic.bold');
const torIcon = tr.querySelector('.tor-icon');
const seedEl = tr.querySelector('.seedmed b');
const leechEl = tr.querySelector('.leechmed b');
const dlEl = tr.querySelector('a.f-dl.dl-stub');
const dlCount = tr.querySelector('[title="Торрент скачан"] b');
return {
topicId,
topicUrl: titleEl?.href ?? '',
rawTitle: titleEl ? (titleEl.getAttribute('title') || titleEl.innerHTML) : '',
parsed: parseTitle(titleEl?.innerHTML ?? ''),
torIconHtml: torIcon?.outerHTML ?? '',
seeders: seedEl?.textContent?.trim() ?? '—',
leechers: leechEl?.textContent?.trim() ?? '—',
dlUrl: dlEl?.href ?? '',
dlSize: dlEl?.textContent?.trim() ?? '',
downloaded: dlCount?.textContent?.trim() ?? '0',
};
}
// ── Разбить продюсера по "/" ──────────────────────────────────────────────
function splitProducer(producer) {
return producer.split(/\s*[\/,/]\s*/).map(s => s.trim()).filter(Boolean);
}
// ── Проверка на японские символы / фильтрация частей через "/" ────────────
function countScript(s) {
return {
j: (s.match(/[\u3040-\u30FF\u4E00-\u9FFF\u3000-\u303F]/g) || []).length,
r: (s.match(/[\u0400-\u04FF]/g) || []).length,
l: (s.match(/[A-Za-z]/g) || []).length,
};
}
function shouldHidePart(p) {
const { j, r, l } = countScript(p);
const total = j + r + l;
if (total === 0) return false; // только цифры/пунктуация — не трогаем
if (j > 0) return !settings.showJapanese; // любой японский символ → японская часть
if (r >= l) return !settings.showRussian; // кириллица не уступает латинице → русская
return !settings.showEnglish;
}
function filterParts(text) {
return text
.split(/\s*\/\s*/)
.map(s => s.trim())
.filter(Boolean)
.filter(p => !shouldHidePart(p))
.join(' / ');
}
// ── Сборка карточки ───────────────────────────────────────────────────────
function buildCard(data, imgUrl) {
const { parsed, torIconHtml, topicUrl, seeders, leechers, dlUrl, dlSize, downloaded } = data;
const { title, version, producer, censorship, year, tags, lang } = parsed;
const C = settings.colors;
const displayTitle = filterParts(title);
const langHtml = (settings.showFlags && lang)
? ` <span class="pl-lang">[${E(lang)}]</span>` : '';
const verHtml = (settings.showVersion && version)
? ` <span class="pl-ver">[${E(version)}]</span>` : '';
const metaParts = [];
if (settings.showProducer && producer) {
const prodParts = splitProducer(producer).filter(p => !shouldHidePart(p));
if (prodParts.length) {
const prodLinks = prodParts
.map(p => `<a href="${searchUrl(p)}" class="pl-prod">${E(p)}</a>`)
.join('<span class="pl-sep"> / </span>');
metaParts.push(prodLinks);
}
}
if (settings.showCensorship && censorship)
metaParts.push(`<span class="pl-cen">${E(censorship)}</span>`);
if (settings.showYear && year)
metaParts.push(`<span class="pl-year">${E(year)}</span>`);
const metaHtml = metaParts.length
? `<div class="pl-meta">${metaParts.join('<span class="pl-sep">, </span>')}</div>` : '';
const statParts = [];
if (settings.showTorrentSize && dlSize)
statParts.push(`<a href="${E(dlUrl)}" class="pl-dlsize" title="Скачать торрент">${E(dlSize)}</a>`);
if (settings.showSeeders)
statParts.push(
`<span class="seedmed" title="Seeders"><b>${E(seeders)}</b></span>` +
`<span class="pl-pipe">|</span>` +
`<span class="leechmed" title="Leechers"><b>${E(leechers)}</b></span>`
);
if (settings.showDownloads)
statParts.push(`<span class="pl-dltimes" title="Скачано">⬇ ${E(downloaded)}</span>`);
const statsHtml = statParts.length
? `<div class="pl-stats">${statParts.join('<span class="pl-dot"> · </span>')}</div>` : '';
let tagsHtml = '';
if (settings.showTags && tags.length) {
const visible = tags.slice(0, settings.tagsMaxVisible);
const hiddenTags = tags.slice(settings.tagsMaxVisible);
const more = hiddenTags.length
? `<span class="pl-tag pl-more" title="Показать скрытые теги" `
+ `data-hidden="${E(JSON.stringify(hiddenTags))}">+${hiddenTags.length}</span>`
: '';
tagsHtml = `<div class="pl-tags">`
+ visible.map(t => `<a href="${searchUrl(t)}" class="pl-tag">${E(t)}</a>`).join('')
+ more + `</div>`;
}
const imgStyle = `max-width:${settings.imgMaxWidth}px;max-height:${settings.imgMaxHeight}px;`;
const imgHtml = imgUrl
? `<img class="pl-img" src="${E(imgUrl)}" loading="lazy" style="${imgStyle}" alt="">`
: `<div class="pl-noimg">Нет обложки</div>`;
return `<div class="pl-card" data-tid="${E(data.topicId)}" style="background:${C.cardBg};border-color:${C.cardBorder}">
<div class="pl-head">
<span class="pl-torico">${torIconHtml}</span>
<a href="${E(topicUrl)}" class="pl-title" style="color:${C.titleColor}">${E(displayTitle)}${verHtml}${langHtml}</a>
</div>
<div class="pl-imgwrap">
<a href="${E(topicUrl)}">${imgHtml}</a>
</div>
${metaHtml}
${statsHtml}
${tagsHtml}
</div>`;
}
// ── CSS ───────────────────────────────────────────────────────────────────
function applyStyles() {
document.getElementById('pl-style')?.remove();
const C = settings.colors;
const cols = settings.columns === 'auto'
? `repeat(auto-fill, minmax(${settings.cardWidth}px, ${settings.cardWidth}px))`
: `repeat(${settings.columns}, ${settings.cardWidth}px)`;
const st = document.createElement('style');
st.id = 'pl-style';
st.textContent = `
/* ── Grid ── */
.pl-grid {
display: grid;
grid-template-columns: ${cols};
gap: ${settings.gap}px;
padding: ${settings.gap}px 0;
justify-content: start;
}
/* ── Card ── */
.pl-card {
width: ${settings.cardWidth}px;
box-sizing: border-box;
border: 1px solid ${C.cardBorder};
border-radius: 6px;
padding: 8px;
background: ${C.cardBg};
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
color: #333;
position: relative;
}
.pl-head {
font-size: ${settings.headFontSize}px;
line-height: 1.35;
word-break: break-word;
text-align: center;
}
.pl-torico .tor-icon { font-size: 11px; }
.pl-title {
color: ${C.titleColor};
text-decoration: none;
font-weight: bold;
}
.pl-title:hover { text-decoration: underline; opacity: 0.8; }
.pl-ver { font-weight: bold; }
/* ── Image ── */
.pl-imgwrap {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 60px;
}
.pl-img {
display: block;
border-radius: 4px;
object-fit: contain;
border: 1px solid #ccc;
}
.pl-noimg {
width: 100%;
padding: 20px 0;
text-align: center;
color: #aaa;
border: 1px dashed #ccc;
border-radius: 4px;
font-size: 11px;
}
.pl-loading {
width: 100%;
padding: 16px 0;
text-align: center;
color: #aaa;
font-size: 11px;
}
/* ── Meta ── */
.pl-meta {
text-align: center;
font-size: ${settings.metaFontSize}px;
word-break: break-word;
color: ${C.metaTextColor};
}
.pl-prod { color: ${C.producerColor}; text-decoration: none; }
.pl-prod:hover { text-decoration: underline; }
.pl-cen { color: ${C.censorColor}; }
.pl-year { color: ${C.yearColor}; }
.pl-sep { color: ${C.sepColor}; }
/* ── Stats ── */
.pl-stats {
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
justify-content: center;
font-size: ${settings.statsFontSize}px;
text-align: center;
}
.pl-dlsize { color: ${C.statSizeColor}; text-decoration: none; font-weight: bold; cursor: pointer; }
.pl-dlsize:hover { text-decoration: underline; }
.pl-pipe { color: ${C.sepColor}; margin: 0 2px; }
.pl-dot { color: ${C.sepColor}; }
.pl-dltimes { color: ${C.dlCountColor}; cursor: default; }
/* ── Tags ── */
.pl-tags { display: flex; flex-wrap: wrap; gap: ${settings.tagsGap}px; justify-content: center;}
.pl-tag {
background: ${C.tagBg};
border: 1px solid ${C.tagBorder};
border-radius: 3px;
padding: 1px 5px;
color: ${C.tagColor};
text-decoration: none;
font-size: ${settings.tagFontSize}px;
white-space: nowrap;
max-width: ${settings.cardWidth - 20}px;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-tag:hover { opacity: 0.75; }
.pl-more { cursor: pointer; color: ${C.moreColor}; border-color: #ccc; background: #e8e8e8; }
/* ── Grid row in table ── */
tr.pl-grid-row td {
padding: 0 !important;
background: transparent !important;
}
/* ── Buttons ── */
.pl-btn {
display: inline-block;
padding: 1px 6px;
background: #f0f0f0;
border: 1px solid #aaaaaa;
border-radius: 2px;
color: #333333;
cursor: pointer;
font-size: 11px;
font-weight: normal;
line-height: 15px;
user-select: none;
text-decoration: none;
vertical-align: middle;
white-space: nowrap;
box-shadow: 1px 1px 0 #fff inset, -1px -1px 0 #ccc inset;
}
.pl-btn:hover { background: #e0e0e0; border-color: #888; color: #000; }
.pl-btn.active { background: #dde0e8; border-color: #7a7aaa; }
.pl-gear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
background: #f0f0f0;
border: 1px solid #aaaaaa;
border-radius: 2px;
cursor: pointer;
font-size: 13px;
font-weight: normal;
color: #333333;
vertical-align: middle;
user-select: none;
line-height: 1;
box-shadow: 1px 1px 0 #fff inset, -1px -1px 0 #ccc inset;
}
.pl-gear:hover { background: #e0e0e0; border-color: #888; }
/* ── Settings Panel — светлая тема ── */
#pl-panel {
position: fixed;
top: 80px;
right: 24px;
width: 310px;
background: #e3e3e3;
border: 1px solid #aaa;
border-radius: 8px;
z-index: 999999;
box-shadow: 0 4px 20px rgba(0,0,0,.35);
color: #222;
font-size: 13px;
display: none;
}
#pl-panel.vis { display: block; }
#pl-ph {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px 9px;
border-bottom: 1px solid #bbb;
cursor: move;
user-select: none;
background: #d0d0d0;
border-radius: 8px 8px 0 0;
}
#pl-ph strong { font-size: 13px; color: #222; }
#pl-px {
background: none; border: none; color: #666; font-size: 18px;
cursor: pointer; line-height: 1; padding: 0 2px;
}
#pl-px:hover { color: #000; }
#pl-pbody {
padding: 12px 14px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.pl-grp { margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #bbb; }
.pl-grp-lbl {
font-size: 10px; color: #777; text-transform: uppercase;
letter-spacing: .6px; margin-bottom: 7px;
}
.pl-row {
display: flex; align-items: center; justify-content: space-between;
gap: 4px; margin-bottom: 6px;
}
.pl-row label { flex: 1; line-height: 1.2; color: #333; }
.pl-row select {
width: 62px; background: #f5f5f5; border: 1px solid #bbb;
color: #333; border-radius: 4px; padding: 2px 5px;
}
.pl-row input[type=number] {
width: 50px; background: #f5f5f5; border: 1px solid #bbb;
color: #333; border-radius: 4px; padding: 2px 5px;
}
.pl-row input[type=checkbox] { accent-color: #345da4; width: 15px; height: 15px; }
.pl-row input[type=color] {
width: 36px; height: 24px; border: 1px solid #bbb;
border-radius: 3px; padding: 1px; cursor: pointer; background: #f5f5f5;
}
.pl-act {
width: 100%; padding: 5px; background: #f0f0f0;
border: 1px solid #bbb; color: #333; border-radius: 4px;
cursor: pointer; margin-top: 4px; font-size: 12px;
}
.pl-act:hover { background: #e0e0e0; border-color: #999; }
`;
document.head.appendChild(st);
}
// ── Определения полей панели (используются и для HTML, и для биндингов) ───
const CB_DEFS = [
['ps-jap', 'showJapanese', 'Японские названия'],
['ps-rus', 'showRussian', 'Русские названия'],
['ps-eng', 'showEnglish', 'Английские названия'],
['ps-flags', 'showFlags', 'Языки'],
['ps-ver', 'showVersion', 'Версия игры'],
['ps-prod', 'showProducer', 'Продюсер'],
['ps-cen', 'showCensorship', 'Цензура'],
['ps-year', 'showYear', 'Год'],
['ps-tags', 'showTags', 'Теги'],
['ps-seed', 'showSeeders', 'Сиды / Личи'],
['ps-size', 'showTorrentSize', 'Размер торрента'],
['ps-dl', 'showDownloads', 'Кол-во скачиваний'],
];
const COLOR_DEFS = [
['pc-cardbg', 'cardBg', 'Фон карточки'],
['pc-cardborder', 'cardBorder', 'Рамка карточки'],
['pc-title', 'titleColor', 'Заголовок'],
['pc-prod', 'producerColor', 'Продюсер'],
['pc-year', 'yearColor', 'Год'],
['pc-censor', 'censorColor', 'Цензура'],
['pc-tagbg', 'tagBg', 'Фон тега'],
['pc-tagtext', 'tagColor', 'Текст тега'],
['pc-tagborder', 'tagBorder', 'Рамка тега'],
['pc-statsize', 'statSizeColor', 'Размер торрента'],
['pc-dlcount', 'dlCountColor', 'Кол-во загрузок'],
['pc-metatext', 'metaTextColor', 'Мета-текст'],
['pc-sep', 'sepColor', 'Разделители ( , · | )'],
['pc-more', 'moreColor', 'Скрытые теги (+N)'],
];
// ── Settings Panel ────────────────────────────────────────────────────────
function buildPanel() {
document.getElementById('pl-panel')?.remove();
const C = settings.colors;
const panel = document.createElement('div');
panel.id = 'pl-panel';
panel.innerHTML = `
<div id="pl-ph">
<strong>⚙️ Настройки миниатюр</strong>
<button id="pl-px">✕</button>
</div>
<div id="pl-pbody">
<div class="pl-grp">
<div class="pl-grp-lbl">Размеры</div>
<div class="pl-row">
<label>Ширина миниатюры</label>
<input type="number" id="ps-cw" min="200" max="400" step="10" value="${settings.cardWidth}">
</div>
<div class="pl-row">
<label>Макс. ширина картинки</label>
<input type="number" id="ps-iw" min="100" max="380" step="10" value="${settings.imgMaxWidth}">
</div>
<div class="pl-row">
<label>Макс. высота картинки</label>
<input type="number" id="ps-ih" min="150" max="600" step="10" value="${settings.imgMaxHeight}">
</div>
<div class="pl-row">
<label>Отступ</label>
<input type="number" id="ps-gap" min="0" max="40" step="2" value="${settings.gap}">
</div>
<div class="pl-row">
<label>Столбцы</label>
<select id="ps-cols">
${['auto','1','2','3','4','5','6'].map(v =>
`<option value="${v}" ${settings.columns === v ? 'selected' : ''}>${v === 'auto' ? 'Авто' : v}</option>`).join('')}
</select>
</div>
<div class="pl-row">
<label>Размер: заголовок</label>
<input type="number" id="ps-hfs" min="9" max="18" step="1" value="${settings.headFontSize}">
</div>
<div class="pl-row">
<label>Размер: продюсер/год</label>
<input type="number" id="ps-mfs" min="8" max="16" step="1" value="${settings.metaFontSize}">
</div>
<div class="pl-row">
<label>Размер: статистика</label>
<input type="number" id="ps-sfs" min="8" max="16" step="1" value="${settings.statsFontSize}">
</div>
<div class="pl-row">
<label>Размер: теги</label>
<input type="number" id="ps-tfs" min="7" max="14" step="1" value="${settings.tagFontSize}">
</div>
<div class="pl-row">
<label>Отступ между тегами</label>
<input type="number" id="ps-tgap" min="0" max="12" step="1" value="${settings.tagsGap}">
</div>
</div>
<div class="pl-grp">
<div class="pl-grp-lbl">Видимые элементы</div>
${CB_DEFS.map(([id, key, lbl]) => `
<div class="pl-row">
<label for="${id}">${lbl}</label>
<input type="checkbox" id="${id}" ${settings[key] ? 'checked' : ''}>
</div>`).join('')}
<div class="pl-row">
<label>Максимум тегов</label>
<input type="number" id="ps-tmax" min="0" max="200" value="${settings.tagsMaxVisible}">
</div>
</div>
<div class="pl-grp">
<div class="pl-grp-lbl">Цвета элементов</div>
${COLOR_DEFS.map(([id, key, lbl]) => `
<div class="pl-row">
<label for="${id}">${lbl}</label>
<input type="color" id="${id}" value="${C[key]}">
</div>`).join('')}
</div>
<div class="pl-grp">
<div class="pl-grp-lbl">Загрузка</div>
<div class="pl-row">
<label>Параллельных запросов</label>
<input type="number" id="ps-conc" min="1" max="10" value="${settings.concurrency}">
</div>
<div class="pl-row">
<label>Кэш обложек (в часах)</label>
<input type="number" id="ps-ttl" min="1" max="720" value="${settings.cacheTTL}">
</div>
<div class="pl-row">
<label>Записей в кэше</label>
<span id="ps-cachecount" style="display:inline-block;width:62px;padding:2px 5px;box-sizing:border-box;">${Object.keys(imgCache).length}</span>
</div>
<button class="pl-act" id="ps-clearcache">🗑 Очистить кэш обложек</button>
</div>
<button class="pl-act" id="ps-reset">↺ Сбросить все настройки</button>
</div>`;
document.body.appendChild(panel);
panel.querySelector('#pl-px').onclick = () => panel.classList.remove('vis');
makeDraggable(panel, panel.querySelector('#pl-ph'));
// Числовые поля — сохраняем, обновляем стили, перерисовываем карточки
function bindNum(id, key) {
const el = panel.querySelector('#' + id);
el.onchange = () => {
settings[key] = +el.value;
saveSettings(); applyStyles();
if (thumbMode) updateCards(true);
};
}
[
['ps-cw', 'cardWidth'],
['ps-iw', 'imgMaxWidth'],
['ps-ih', 'imgMaxHeight'],
['ps-gap', 'gap'],
['ps-hfs', 'headFontSize'],
['ps-mfs', 'metaFontSize'],
['ps-sfs', 'statsFontSize'],
['ps-tfs', 'tagFontSize'],
['ps-tgap', 'tagsGap'],
].forEach(([id, key]) => bindNum(id, key));
panel.querySelector('#ps-cols').onchange = e => {
settings.columns = e.target.value;
saveSettings(); applyStyles();
};
// Чекбоксы
for (const [id, key] of CB_DEFS) {
panel.querySelector('#' + id).onchange = e => {
settings[key] = e.target.checked;
saveSettings();
if (thumbMode) updateCards();
};
}
panel.querySelector('#ps-tmax').onchange = e => {
settings.tagsMaxVisible = +e.target.value || 60;
saveSettings(); if (thumbMode) updateCards();
};
panel.querySelector('#ps-conc').onchange = e => {
settings.concurrency = Math.max(1, +e.target.value || 6);
saveSettings();
};
panel.querySelector('#ps-ttl').onchange = e => {
settings.cacheTTL = Math.max(1, +e.target.value || 48);
saveSettings();
};
// Цвета
for (const [id, key] of COLOR_DEFS) {
panel.querySelector('#' + id).oninput = e => {
settings.colors[key] = e.target.value;
saveSettings(); applyStyles();
if (thumbMode) updateCards();
};
}
panel.querySelector('#ps-clearcache').onclick = () => {
imgCache = {}; saveCache();
panel.querySelector('#ps-cachecount').textContent = '0';
};
panel.querySelector('#ps-reset').onclick = () => {
settings = { ...DEFAULTS, colors: { ...DEFAULTS.colors } };
saveSettings(); applyStyles(); buildPanel();
document.getElementById('pl-panel').classList.add('vis');
if (thumbMode) updateCards();
};
}
function makeDraggable(el, handle) {
let dragging = false, ox = 0, oy = 0;
handle.addEventListener('mousedown', e => {
dragging = true;
const r = el.getBoundingClientRect();
ox = e.clientX - r.left; oy = e.clientY - r.top;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
el.style.left = (e.clientX - ox) + 'px';
el.style.top = (e.clientY - oy) + 'px';
el.style.right = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
}
// ── Обновление карточек ───────────────────────────────────────────────────
// live=true: при изменении размеров — обновляем стили img инлайн, если обложка
// ещё не загружена (нет кэша); пропускаем карточки без кэша иначе.
// live=false (по умолчанию): перестраиваем только карточки с готовым кэшем.
function updateCards(live = false) {
document.querySelectorAll('.pl-card').forEach(card => {
const tid = card.dataset.tid;
const data = topicData.get(tid);
const cached = imgCache[tid];
if (data && cached !== undefined) {
const tmp = document.createElement('div');
tmp.innerHTML = buildCard(data, cached.url);
card.replaceWith(tmp.firstElementChild);
} else if (live) {
const img = card.querySelector('.pl-img');
if (img) {
img.style.maxWidth = settings.imgMaxWidth + 'px';
img.style.maxHeight = settings.imgMaxHeight + 'px';
}
}
});
}
// ── Получить строки по секциям ────────────────────────────────────────────
const CONVERT_SECTIONS = ['прилеплен', 'топик'];
function getSectionedData() {
const table = document.querySelector('table.forumline.forum');
if (!table) return [];
const result = [];
let current = null;
const rows = [...table.querySelectorAll('tbody tr')];
for (const row of rows) {
const sep = row.querySelector('td.topicSep');
if (sep) {
if (current) result.push(current);
const text = sep.textContent.trim().toLowerCase();
const shouldConvert = CONVERT_SECTIONS.some(k => text.includes(k));
current = { sepTr: row, shouldConvert, rows: [] };
continue;
}
if (current && current.shouldConvert && row.id.startsWith('tr-')
&& (row.querySelector('.seedmed') || row.querySelector('.f-dl'))) {
current.rows.push(row);
}
}
if (current) result.push(current);
// Фолбэк для пагинированных страниц (start=N), где нет строк-разделителей
if (!result.length) {
const fallbackRows = rows.filter(row =>
row.id.startsWith('tr-') &&
(row.querySelector('.seedmed') || row.querySelector('.f-dl'))
);
if (fallbackRows.length) {
const firstTopic = fallbackRows[0];
let anchor = firstTopic.previousElementSibling;
if (!anchor) {
anchor = document.createElement('tr');
anchor.className = 'pl-phantom-sep';
firstTopic.parentNode.insertBefore(anchor, firstTopic);
}
result.push({ sepTr: anchor, shouldConvert: true, rows: fallbackRows });
}
}
return result;
}
// ── Вставить строку с гридом после строки-разделителя ────────────────────
function insertGridRowAfter(refTr) {
const existing = refTr.nextElementSibling;
if (existing && existing.classList.contains('pl-grid-row')) return existing;
const colCount = refTr.closest('table')
?.querySelector('colgroup col')
? refTr.closest('table').querySelectorAll('colgroup col').length
: 6;
const tr = document.createElement('tr');
tr.className = 'pl-grid-row';
const td = document.createElement('td');
td.colSpan = colCount;
td.style.padding = '0';
const gridDiv = document.createElement('div');
gridDiv.className = 'pl-grid';
td.appendChild(gridDiv);
tr.appendChild(td);
refTr.insertAdjacentElement('afterend', tr);
return tr;
}
// ── Активация режима миниатюр ─────────────────────────────────────────────
async function activateThumbMode() {
const sections = getSectionedData();
const convertSections = sections.filter(s => s.shouldConvert && s.rows.length);
if (!convertSections.length) return;
for (const sec of convertSections)
for (const row of sec.rows) row.style.display = 'none';
document.querySelectorAll('tr.pl-grid-row').forEach(tr => tr.remove());
topicData = new Map();
for (const sec of convertSections)
for (const row of sec.rows) {
const d = parseTr(row);
topicData.set(d.topicId, d);
}
for (const sec of convertSections) {
const gridTr = insertGridRowAfter(sec.sepTr);
const gridDiv = gridTr.querySelector('.pl-grid');
for (const row of sec.rows) {
const data = topicData.get(row.id.slice(3));
if (!data) continue;
const tmp = document.createElement('div');
tmp.innerHTML = `<div class="pl-card" data-tid="${E(data.topicId)}" style="background:${settings.colors.cardBg};border-color:${settings.colors.cardBorder}">
<div class="pl-head">
<span class="pl-torico">${data.torIconHtml}</span>
<a href="${E(data.topicUrl)}" class="pl-title" style="color:${settings.colors.titleColor}">${E(data.parsed.title)}${data.parsed.version ? ` <span class="pl-ver">[${E(data.parsed.version)}]</span>` : ''}</a>
${(settings.showFlags && data.parsed.lang) ? ` <span class="pl-lang">[${E(data.parsed.lang)}]</span>` : ''}
</div>
<div class="pl-imgwrap"><div class="pl-loading">⌛ Загрузка обложки…</div></div>
</div>`;
gridDiv.appendChild(tmp.firstElementChild);
}
}
// Параллельная загрузка обложек
const dataList = [...topicData.values()];
let idx = 0;
async function worker() {
while (idx < dataList.length) {
const data = dataList[idx++];
const card = document.querySelector(`.pl-card[data-tid="${data.topicId}"]`);
const url = await fetchThumb(data.topicId, data.topicUrl);
if (card) {
const tmp = document.createElement('div');
tmp.innerHTML = buildCard(data, url);
card.replaceWith(tmp.firstElementChild);
}
}
}
await Promise.all(
Array.from({ length: Math.min(settings.concurrency, dataList.length) }, worker)
);
}
function deactivateThumbMode() {
document.querySelectorAll('tr.pl-grid-row').forEach(tr => tr.remove());
document.querySelectorAll('tr[id^="tr-"]').forEach(tr => {
if (tr.style.display === 'none') tr.style.display = '';
});
}
// ── Кнопки ────────────────────────────────────────────────────────────────
function insertButtons() {
const optLink = document.querySelector('a.menu-root[href*="only-new-options"]');
if (!optLink) return;
const container = optLink.parentNode;
const modeBtn = document.createElement('span');
modeBtn.id = 'pl-modebtn';
modeBtn.className = 'pl-btn' + (thumbMode ? ' active' : '');
modeBtn.textContent = thumbMode ? 'стандартный режим' : 'режим миниатюр';
modeBtn.style.cssText = 'margin-left:10px;';
const gearBtn = document.createElement('span');
gearBtn.className = 'pl-gear';
gearBtn.title = 'Настройки миниатюр';
gearBtn.textContent = '⚙️';
gearBtn.style.cssText = 'margin-left:4px;';
const ref = optLink.nextSibling;
container.insertBefore(modeBtn, ref);
container.insertBefore(gearBtn, modeBtn.nextSibling);
modeBtn.addEventListener('click', async () => {
thumbMode = !thumbMode;
lsSet('plThumb_mode', thumbMode);
if (thumbMode) {
modeBtn.textContent = '⌛ Загрузка…';
modeBtn.classList.add('active');
await activateThumbMode();
modeBtn.textContent = 'Стандартный режим';
} else {
modeBtn.textContent = 'Режим миниатюр';
modeBtn.classList.remove('active');
deactivateThumbMode();
}
});
gearBtn.addEventListener('click', () => {
let panel = document.getElementById('pl-panel');
if (!panel) { buildPanel(); panel = document.getElementById('pl-panel'); }
panel.classList.toggle('vis');
});
}
// ── Init ──────────────────────────────────────────────────────────────────
loadAll();
applyStyles();
insertButtons();
// Раскрытие скрытых тегов по клику на +N
document.addEventListener('click', e => {
const btn = e.target.closest('.pl-more[data-hidden]');
if (!btn) return;
let hidden;
try { hidden = JSON.parse(btn.dataset.hidden); } catch { return; }
const tagsDiv = btn.closest('.pl-tags');
if (!tagsDiv) return;
hidden.forEach(t => {
const a = document.createElement('a');
a.href = searchUrl(t);
a.className = 'pl-tag';
a.textContent = t;
tagsDiv.insertBefore(a, btn);
});
btn.remove();
});
if (thumbMode) activateThumbMode();
})();