GameVN Scripts

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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