Pornolab: Thumbnail Viewing Mode

Добавляет режим отображения миниатюр на форуме Pornolab

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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,'&amp;').replace(/</g,'&lt;')
        .replace(/>/g,'&gt;').replace(/"/g,'&quot;');

    // ── Поисковая ссылка ──────────────────────────────────────────────────────
    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();

})();