Greasy Fork is available in English.

GameVN Scripts

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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