GameVN Scripts

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

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

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

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

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

이 스크립트를 설치하려면 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);
})();