GameVN Scripts

Adds a custom sticker box, quick-links nav bar, and white text for dark mode on XenForo 2.x.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         GameVN Scripts
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Adds a custom sticker box, quick-links nav bar, and white text for dark mode on XenForo 2.x.
// @author       JR@gvn
// @match        *://gvn.co/*
// @match        *://*.gvn.co/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      imgur.com
// ==/UserScript==

(function () {
    'use strict';

    /* ── Configuration ─────────────────────────────────────────── */

    const CONFIG = {
        albums: [
            { id: 'pZxee8m', name: 'Mèo 25/26' },
            { id: 'v6H7yXQ', name: 'Mèo 23/24' },
            { id: 'C8UKRFY', name: 'Peepo' },
            { id: 'eRCCtp1', name: 'Fat Cat' },
        ],
        stickerSize: '80px',
        clientId: '546c25a59c58ad7',
        cacheKeyPrefix: 'xf_sticker_cache_v3_',
        cacheTime: 24 * 60 * 60 * 1000, // 24 hours
        quickLinks: [
            { name: 'Tin game', url: 'https://f.gvn.co/forums/tin-tuc-gioi-thieu-thao-luan-chung-ve-game.21/' },
            { name: 'Thư giãn', url: 'https://f.gvn.co/forums/thu-gian.50/' },
            { name: 'Phim ảnh', url: 'https://f.gvn.co/forums/phim-anh.41/' },
            { name: 'Thể thao', url: 'https://f.gvn.co/forums/the-thao.53/' },
        ],
    };

    const IDS = {
        darkStyle: 'xf-darkmode-post-text',
        container: 'xf-sticker-bar',
        tabs: 'xf-sticker-tabs',
        grid: 'xf-sticker-grid',
    };

    /* ── Helpers ────────────────────────────────────────────────── */

    const $ = (sel, root = document) => root.querySelector(sel);
    const $$ = (sel, root = document) => root.querySelectorAll(sel);

    const isDarkMode = () =>
        document.documentElement.getAttribute('data-variation') === 'alternate';

    function onReady(fn) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', fn, { once: true });
        } else {
            fn();
        }
    }

    function setStatusMessage(container, text) {
        container.innerHTML =
            `<div style="grid-column:1/-1;text-align:center;color:#888;padding:20px">${text}</div>`;
    }

    /* ── Dark-Mode Text Fix ────────────────────────────────────── */

    const DARK_MODE_CSS = `
        /* Post text */
        .message-body .bbWrapper, .message-content .bbWrapper,
        .message-userContent .bbWrapper, .bbWrapper,
        article.message-body, .message-body, .message-content,
        .message-userContent, .block-body .message,
        .p-body-content .message, .message-cell--main,
        .message-inner, .js-post .message-body, .message-attribution,
        .message-body p, .message-body span, .message-body div,
        .message-body li, .message-body td, .message-body th {
            color: #fff !important;
        }
        /* Links */
        .message-body a, .bbWrapper a { color: #8cb4ff !important; }
        /* Quotes */
        .bbCodeBlock--quote .bbCodeBlock-content { color: #e0e0e0 !important; }
        /* Code blocks */
        .bbCodeBlock--code .bbCodeBlock-content { color: #f0f0f0 !important; }
        /* Editor */
        .fr-element, .fr-element p, .fr-view, .fr-view p,
        .fr-box .fr-element, .fr-wrapper .fr-element,
        .editorContent, .js-editor .fr-element,
        .formSubmitRow-main textarea, textarea.input,
        .input--textarea, .bbCodeQuote-content {
            color: #fff !important;
        }
        .fr-placeholder { color: #aaa !important; }
    `;

    function applyDarkModeStyles() {
        const existing = document.getElementById(IDS.darkStyle);

        if (!isDarkMode()) {
            if (existing) existing.remove();
            return;
        }

        if (existing) return;

        const style = document.createElement('style');
        style.id = IDS.darkStyle;
        style.textContent = DARK_MODE_CSS;
        document.head.appendChild(style);
    }

    function initDarkMode() {
        onReady(applyDarkModeStyles);
        new MutationObserver(applyDarkModeStyles)
            .observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['data-variation'],
            });
    }

    /* ── Quick Links Nav Bar ─────────────────────────────────── */

    function injectQuickLinks() {
        if (document.querySelector('.gvn-quicklink')) return true;

        const navList =
            document.querySelector('ul.p-nav-list') ||
            document.querySelector('.p-nav-list') ||
            document.querySelector('.p-nav-inner ul');
        if (!navList) return false;

        for (const link of CONFIG.quickLinks) {
            const li = document.createElement('li');
            li.className = 'gvn-quicklink';

            const navEl = document.createElement('div');
            navEl.className = 'p-navEl';

            const a = document.createElement('a');
            a.href = link.url;
            a.className = 'p-navEl-link';
            a.setAttribute('data-nav-id', link.name.toLowerCase().replace(/\s+/g, '-'));
            a.textContent = link.name;

            if (window.location.href.includes(link.url.replace(/\/$/, ''))) {
                navEl.classList.add('is-selected');
            }

            navEl.appendChild(a);
            li.appendChild(navEl);
            navList.appendChild(li);
        }

        return true;
    }

    function initQuickLinks() {
        if (injectQuickLinks()) return;

        // Retry: nav may not be in the DOM yet
        let tries = 0;
        const timer = setInterval(() => {
            if (injectQuickLinks() || ++tries > 30) clearInterval(timer);
        }, 300);
    }

    /* ── XF Editor Bridge ──────────────────────────────────────── */

    function insertIntoEditor(html) {
        if (typeof XF === 'undefined') return;

        const el = $('.js-editor');
        if (!el) return;

        const handler =
            XF.Element.getHandler(el, 'editor') ||
            XF.Element.getHandler(el, 'wysiwyg');
        if (!handler) return;

        if (handler.editor?.html?.insert) {
            handler.editor.html.insert(html);
        } else if (typeof handler.insertContent === 'function') {
            handler.insertContent(html);
        }
    }

    /* ── Imgur API ─────────────────────────────────────────────── */

    function fetchAlbum(albumId) {
        return new Promise((resolve) => {
            const cacheKey = CONFIG.cacheKeyPrefix + albumId;

            // Try cache first
            try {
                const cached = JSON.parse(GM_getValue(cacheKey, 'null'));
                if (cached && Date.now() - cached.timestamp < CONFIG.cacheTime) {
                    return resolve(cached.images);
                }
            } catch { /* cache miss */ }

            // Fetch from API
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.imgur.com/3/album/${albumId}/images`,
                headers: { Authorization: `Client-ID ${CONFIG.clientId}` },
                onload(response) {
                    try {
                        const { success, data } = JSON.parse(response.responseText);
                        if (!success || !data) return resolve([]);

                        const images = data.map((img) => img.link);
                        GM_setValue(cacheKey, JSON.stringify({ timestamp: Date.now(), images }));
                        resolve(images);
                    } catch {
                        resolve([]);
                    }
                },
                onerror() { resolve([]); },
            });
        });
    }

    /* ── Sticker Box UI ────────────────────────────────────────── */

    const TAB_STYLE_BASE = `
        border: 1px solid var(--border-color, #444);
        background: var(--content-alt-bg, transparent);
        color: var(--text-color, inherit);
        padding: 5px 12px;
        cursor: pointer;
        border-radius: 4px;
        font-family: inherit;
        font-size: 13px;
        transition: all .2s;
    `;

    let activeAlbumId = null;

    function setTabActive(btn, isActive) {
        if (isActive) {
            btn.style.background = 'var(--button-bg-primary, #005fad)';
            btn.style.color = '#fff';
            btn.style.borderColor = 'transparent';
        } else {
            btn.style.background = 'var(--content-alt-bg, transparent)';
            btn.style.color = 'var(--text-color, inherit)';
            btn.style.borderColor = 'var(--border-color, #444)';
        }
    }

    function switchTab(albumId) {
        activeAlbumId = albumId;

        const grid = document.getElementById(IDS.grid);
        if (!grid) return;

        // Highlight active tab
        $$(`#${IDS.tabs} button`).forEach((btn) => {
            const album = CONFIG.albums.find((a) => a.name === btn.textContent);
            setTabActive(btn, album?.id === albumId);
        });

        setStatusMessage(grid, 'Loading stickers…');

        fetchAlbum(albumId).then((images) => {
            if (activeAlbumId !== albumId) return; // tab changed while loading
            renderGrid(grid, images);
        });
    }

    function renderGrid(container, images) {
        container.innerHTML = '';

        if (!images.length) {
            setStatusMessage(container, 'No images found or API error.');
            return;
        }

        for (const src of images) {
            const cell = document.createElement('div');
            cell.title = 'Insert Sticker';
            cell.style.cssText = `
                display: flex; align-items: center; justify-content: center;
                height: 80px; cursor: pointer; border-radius: 4px;
                transition: background .15s, transform .1s;
                background: transparent; border: none;
            `;

            const img = document.createElement('img');
            img.src = src;
            img.loading = 'lazy';
            img.style.cssText =
                'max-width:95%; max-height:95%; object-fit:contain; pointer-events:none';

            cell.addEventListener('mouseenter', () => {
                cell.style.background = 'rgba(0,0,0,.05)';
                cell.style.transform = 'scale(1.05)';
            });
            cell.addEventListener('mouseleave', () => {
                cell.style.background = 'transparent';
                cell.style.transform = 'scale(1)';
            });
            cell.addEventListener('click', () => {
                insertIntoEditor(
                    `<img src="${src}" alt="Sticker" style="max-height:${CONFIG.stickerSize}"> `
                );
            });

            cell.appendChild(img);
            container.appendChild(cell);
        }
    }

    function renderTabs(tabsContainer) {
        tabsContainer.innerHTML = '';

        for (const album of CONFIG.albums) {
            const tab = document.createElement('button');
            tab.textContent = album.name;
            tab.type = 'button';
            tab.className = 'sticker-tab';
            tab.style.cssText = TAB_STYLE_BASE;
            tab.addEventListener('mouseenter', () => {
                if (activeAlbumId !== album.id) {
                    tab.style.background = 'var(--button-bg-primary, #005fad)';
                    tab.style.color = '#fff';
                    tab.style.borderColor = 'transparent';
                    tab.style.transform = 'translateY(-1px)';
                }
            });
            tab.addEventListener('mouseleave', () => {
                if (activeAlbumId !== album.id) {
                    tab.style.background = 'var(--content-alt-bg, transparent)';
                    tab.style.color = 'var(--text-color, inherit)';
                    tab.style.borderColor = 'var(--border-color, #444)';
                    tab.style.transform = 'translateY(0)';
                }
            });
            tab.addEventListener('click', (e) => {
                e.preventDefault();
                switchTab(album.id);
            });
            tabsContainer.appendChild(tab);
        }
    }

    function renderWidget() {
        if (document.getElementById(IDS.container)) return;

        const editor = $('.js-editor');
        if (!editor) return;

        const container = document.createElement('div');
        container.id = IDS.container;
        container.style.cssText = `
            background: transparent; border: none;
            margin: 10px 0 8px; display: flex; flex-direction: column;
        `;

        const tabsEl = document.createElement('div');
        tabsEl.id = IDS.tabs;
        tabsEl.style.cssText =
            'display:flex; gap:5px; padding:0 5px; margin-bottom:5px; flex-wrap:wrap';

        const gridEl = document.createElement('div');
        gridEl.id = IDS.grid;
        gridEl.style.cssText = `
            padding: 12px 5px; background: transparent; border: none;
            display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
            gap: 10px; max-height: 290px; overflow-y: auto;
            scrollbar-width: thin; min-height: 90px;
        `;
        setStatusMessage(gridEl, 'Select a sticker pack');

        container.append(tabsEl, gridEl);
        editor.after(container);

        renderTabs(tabsEl);

        if (CONFIG.albums.length) {
            switchTab(CONFIG.albums[0].id);
        }
    }

    /* ── Bootstrap ─────────────────────────────────────────────── */

    function initStickerBar() {
        if (typeof XF === 'undefined') return;

        renderWidget();

        // Re-render when editor appears dynamically (e.g. quick reply)
        new MutationObserver(() => {
            if ($('.js-editor') && !document.getElementById(IDS.container)) {
                setTimeout(renderWidget, 150);
            }
        }).observe(document.body, { childList: true, subtree: true });
    }

    initDarkMode();
    onReady(initQuickLinks);
    onReady(initStickerBar);
})();