Pornolab: Thumbnail Viewing Mode

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();

})();