SGDB Follow Users

Client-side follow system for SteamGridDB user profiles.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @version             2.0
// @name                SGDB Follow Users
// @name:zh-CN          SGDB 关注用户
// @name:ja             SGDB ユーザーフォロー
// @name:ru             SGDB Подписчики
// @name:es             SGDB Seguidores
// @name:pt-PT          SGDB A Seguir
// @description         Client-side follow system for SteamGridDB user profiles.
// @description:zh-CN   SteamGridDB 用户资料的关注系统。
// @description:ja      SteamGridDB のユーザープロフィール用フォローシステム。
// @description:ru      Клиентская система подписки для профилей SteamGridDB.
// @description:es      Sistema de seguimiento del lado del cliente para perfiles de SteamGridDB.
// @description:pt-PT   Sistema de seguimento do lado do cliente para perfis de SteamGridDB.
// @grant               GM_addStyle
// @grant               GM.getValue
// @grant               GM.setValue
// @grant               GM_deleteValue
// @grant               GM_registerMenuCommand
// @grant               GM_notification
// @run-at              document-idle\
// @match               https://www.steamgriddb.com/*
// @namespace           https://www.steamgriddb.com/
// @icon                https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/391540/2ce672b89b63ec1e70d2f12862e72eb4a33e9268.jpg
// @license             MIT
// @author              okagame
// ==/UserScript==

(function()
{
    'use strict';

    // V- CONFIGURATION & ASSETS -V

    // Unique key for browser storage
    const STORAGE_KEY = 'sgdb_followed_users';

    // CSS Selectors for DOM interaction
    const CONTAINER_STATS = '.stats';
    const CONTAINER_NAV = 'nav ul';

    // Icon: Empty Heart (Not Following)
    const ICON_HEART_OUTLINE = `
        <svg class="icon" viewBox="0 0 1024 1024">
            <path d="M725.333 192c-89.6 0-168.533 44.8-213.333 117.333C467.2 236.8 388.267 192 298.667 192 157.867 192 42.667 307.2 42.667 448c0 253.867 469.333 469.333 469.333 469.333s469.333-215.467 469.333-469.333C981.333 307.2 866.133 192 725.333 192z M512 832c-65.067-36.267-384-221.867-384-384 0-106.027 85.973-192 192-192 70.4 0 132.267 38.4 165.333 96 33.067-57.6 94.933-96 165.333-96 106.027 0 192 85.973 192 192 0 162.133-318.933 347.733-384 384z"></path>
        </svg>`;

    // Icon: Filled Heart (Following)
    const ICON_HEART_FILLED = `
        <svg class="icon" viewBox="0 0 1024 1024">
            <path d="M725.333 192c-89.6 0-168.533 44.8-213.333 117.333C467.2 236.8 388.267 192 298.667 192 157.867 192 42.667 307.2 42.667 448c0 253.867 469.333 469.333 469.333 469.333s469.333-215.467 469.333-469.333C981.333 307.2 866.133 192 725.333 192z"></path>
        </svg>`;

    // Icon: Small Filled Heart (For Dropdown)
    const ICON_HEART_SMALL = `<svg class="icon" viewBox="0 0 1024 1024"><path d="M725.333 192c-89.6 0-168.533 44.8-213.333 117.333C467.2 236.8 388.267 192 298.667 192 157.867 192 42.667 307.2 42.667 448c0 253.867 469.333 469.333 469.333 469.333s469.333-215.467 469.333-469.333C981.333 307.2 866.133 192 725.333 192z"/></svg>`;


    // V- STYLES -V

    const styles = `
        /* Profile Button Active State */
        .sgdb-follow-btn.active .icon
        {
            fill: #ff4081;
            color: #ff4081;
            transition: color 0.2s, fill 0.2s;
        }

        /* Nav Dropdown Trigger */
        .nav-item #sgdb-follow-trigger .icon
        {
            cursor: pointer;
            fill: currentColor;
            transition: color 0.2s;
        }

        .nav-item #sgdb-follow-trigger:hover .icon
        {
            color: #ff4081;
        }

        /* Dropdown Container */
        #sgdb-dropdown
        {
            position: absolute;
            top: 100%;
            right: 0;
            width: 320px;
            margin-top: 10px;
            display: none;
            z-index: 9999;
        }

        #sgdb-dropdown.is-open
        {
            display: block;
            animation: fadeIn 0.2s ease-out;
        }

        /* Tippy Box Mimicry */
        .sgdb-tippy-box
        {
            background-color: #1b1b1b;
            color: #ddd;
            border-radius: 8px;
            padding: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.5);
            border: 1px solid #333;
        }

        /* Dropdown List */
        .sgdb-dropdown-list
        {
            max-height: 400px;
            overflow-y: auto;
        }

        .sgdb-dropdown-item
        {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 8px;
            border-radius: 4px;
            transition: background 0.1s;
        }

        .sgdb-dropdown-item:hover
        {
            background-color: rgba(255,255,255,0.05);
        }

        .sgdb-item-info
        {
            display: flex;
            align-items: center;
            gap: 10px;
            flex: 1;
        }

        .sgdb-item-avatar
        {
            width: 24px;
            height: 24px;
            border-radius: 50%;
        }

        .sgdb-item-name
        {
            font-size: 0.9rem;
            color: #eee;
            text-decoration: none;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: 140px;
        }

        .sgdb-item-name:hover
        {
            color: #ff4081;
        }

        .sgdb-mini-unfollow
        {
            background: transparent;
            border: none;
            color: #666;
            cursor: pointer;
            font-size: 0.8rem;
            padding: 2px 6px;
        }

        .sgdb-mini-unfollow:hover
        {
            color: #ff4081;
            background: rgba(255, 64, 129, 0.1);
            border-radius: 4px;
        }

        .sgdb-dropdown-header
        {
            font-size: 0.85rem;
            text-transform: uppercase;
            letter-spacing: 1px;
            color: #888;
            padding: 0 8px 8px 8px;
            border-bottom: 1px solid #333;
            margin-bottom: 8px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .sgdb-open-manager
        {
            cursor: pointer;
            color: #666;
            font-size: 0.75rem;
        }

        .sgdb-open-manager:hover
        {
            color: #fff;
        }

        .sgdb-empty-msg
        {
            text-align: center;
            padding: 20px;
            color: #666;
            font-size: 0.9rem;
        }

        /* Modal Backdrop */
        #sgdb-modal-backdrop
        {
            position: fixed;
            top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0, 0, 0, 0.8);
            z-index: 10000;
            display: none;
            justify-content: center;
            align-items: center;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }

        #sgdb-modal-backdrop.active
        {
            display: flex;
        }

        #sgdb-modal-content
        {
            background: #1b1b1b;
            color: white;
            width: 90%;
            max-width: 600px;
            max-height: 80vh;
            border-radius: 8px;
            padding: 25px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.5);
            overflow-y: auto;
            position: relative;
        }

        /* Animations */
        @keyframes fadeIn
        {
            from { opacity: 0; transform: translateY(-5px); }
            to { opacity: 1; transform: translateY(0); }
        }

        /* Scrollbar */
        .sgdb-dropdown-list::-webkit-scrollbar { width: 6px; }
        .sgdb-dropdown-list::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
        .sgdb-dropdown-list::-webkit-scrollbar-track { background: transparent; }
    `;

    GM_addStyle(styles);


    // V- DATA STORAGE FUNCTIONS -V

    // Retrieve followed users. Returns empty object if storage fails.
    async function load_follows()
    {
        let return_data = {};

        try
        {
            return_data = await GM.getValue(STORAGE_KEY, {});
        }
        catch (error)
        {
            console.error("Failed to load follows: ", error);
            return_data = {};
        }

        return return_data;
    }

    // Save data to storage and trigger UI update.
    async function save_follows(data)
    {
        let save_success = false;

        try
        {
            await GM.setValue(STORAGE_KEY, data);
            update_dropdown_content(data);
            save_success = true;
        }
        catch (error)
        {
            console.error("Failed to save follows: ", error);
            save_success = false;

            // Alert user if save fails
            GM_notification(
                {
                    text: "Error saving follow list.",
                    title: "SGDB Script Error",
                    timeout: 3000
                }
            );
        }

        return save_success;
    }


    // V- UTILITY FUNCTIONS -V

    // Extract User ID from current URL. Returns null if not on a profile page.
    function get_current_user_id()
    {
        let user_id = null;
        const path_parts = window.location.pathname.split('/');

        // Check if URL structure matches a profile page
        if (path_parts[1] === 'profile' && path_parts[2])
        {
            user_id = path_parts[2];
        }

        // Return the determined state
        return user_id;
    }

    // Scrape user details (Name/Avatar/ID) from the DOM.
    function get_profile_details()
    {
        const name_element = document.querySelector('.column.details h1');
        const avatar_element = document.querySelector('.column.avatar img');

        let return_details =
        {
            name: name_element ? name_element.textContent.trim() : 'Unknown User',
            avatar: avatar_element ? avatar_element.src : '',
            id: get_current_user_id()
        };

        return return_details;
    }


    // V- DOM RENDERING -V

    // Create the Follow/Unfollow button element.
    function create_follow_button(is_following)
    {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = `btn-link sgdb-follow-btn ${is_following ? 'active' : ''}`;
        btn.title = is_following ? 'Unfollow User' : 'Follow User';
        btn.innerHTML = is_following ? ICON_HEART_FILLED : ICON_HEART_OUTLINE;

        btn.addEventListener('click', async (event) =>
        {
            event.preventDefault();
            const profile = get_profile_details();
            const follows = await load_follows();

            if (follows[profile.id])
            {
                // Unfollow logic
                delete follows[profile.id];
                btn.innerHTML = ICON_HEART_OUTLINE;
                btn.classList.remove('active');
                btn.title = 'Follow User';
                GM_notification({ text: `Unfollowed ${profile.name}`, timeout: 2000 });
            }
            else
            {
                // Follow logic
                follows[profile.id] = { name: profile.name, avatar: profile.avatar, followedAt: Date.now() };
                btn.innerHTML = ICON_HEART_FILLED;
                btn.classList.add('active');
                btn.title = 'Unfollow User';
                GM_notification({ text: `Following ${profile.name}`, timeout: 2000 });
            }

            await save_follows(follows);
        });

        return btn;
    }

    // Render the profile button if conditions are met.
    async function render_profile_button()
    {
        let should_render = false;

        // Verify context
        const current_id = get_current_user_id();
        const container = document.querySelector(CONTAINER_STATS);

        if (current_id && container)
        {
            should_render = true;
        }

        // Check existence to prevent duplicates
        if (container && container.querySelector('.sgdb-follow-btn'))
        {
            should_render = false;
        }

        if (should_render)
        {
            const follows = await load_follows();
            const profile = get_profile_details();
            const is_following = !!follows[profile.id];

            // Create Separator if needed
            const last_child = container.lastElementChild;
            if (last_child && !last_child.classList.contains('sgdb-separator') && !last_child.classList.contains('sgdb-follow-btn'))
            {
                const separator = document.createElement('span');
                separator.textContent = ' • ';
                separator.className = 'sgdb-separator';
                container.appendChild(separator);
            }

            // Inject Button
            const btn = create_follow_button(is_following);
            container.appendChild(btn);
        }
    }

    // Create the Navigation Dropdown Trigger.
    function render_nav_trigger()
    {
        let should_render = false;
        const nav_ul = document.querySelector(CONTAINER_NAV);

        if (nav_ul && !document.getElementById('sgdb-nav-item'))
        {
            should_render = true;
        }

        if (should_render)
        {
            const li = document.createElement('li');
            li.className = 'nav-item right dropdown';
            li.id = 'sgdb-nav-item';
            li.innerHTML = `
                <span id="sgdb-follow-trigger" class="icon-wrap">
                    ${ICON_HEART_FILLED}
                    <span class="hide-from-lg">Following</span>
                </span>
                <div id="sgdb-dropdown" class="tippy-box sgdb-tippy-box">
                    <div class="sgdb-dropdown-header">
                        <span>Followed Users</span>
                        <span class="sgdb-open-manager" id="sgdb-open-manager">Manage All</span>
                    </div>
                    <div class="sgdb-dropdown-list" id="sgdb-dropdown-content">
                        <!-- Content Injected via JS -->
                    </div>
                </div>
            `;

            nav_ul.appendChild(li);

            // Toggle Logic
            const trigger = document.getElementById('sgdb-follow-trigger');
            const dropdown = document.getElementById('sgdb-dropdown');
            const manager_link = document.getElementById('sgdb-open-manager');

            trigger.addEventListener('click', (event) =>
            {
                event.stopPropagation();
                dropdown.classList.toggle('is-open');
            });

            manager_link.addEventListener('click', (event) =>
            {
                event.stopPropagation();
                dropdown.classList.remove('is-open');
                open_manager();
            });

            // Close on outside click
            document.addEventListener('click', (event) =>
            {
                if (!li.contains(event.target))
                {
                    dropdown.classList.remove('is-open');
                }
            });
        }
    }

    // Update the dropdown HTML content based on data.
    async function update_dropdown_content(data)
    {
        const content_div = document.getElementById('sgdb-dropdown-content');

        if (!content_div)
        {
            return;
        }

        const ids = Object.keys(data);
        let html_output = '';

        if (ids.length === 0)
        {
            html_output = `<div class="sgdb-empty-msg">You are not following anyone.</div>`;
        }
        else
        {
            ids.forEach(id =>
            {
                const user = data[id];
                html_output += `
                    <div class="sgdb-dropdown-item">
                        <div class="sgdb-item-info">
                            <img src="${user.avatar}" class="sgdb-item-avatar" alt="">
                            <a href="/profile/${id}" class="sgdb-item-name">${user.name}</a>
                        </div>
                        <button class="sgdb-mini-unfollow" data-id="${id}">Unfollow</button>
                    </div>
                `;
            });
        }

        content_div.innerHTML = html_output;

        // Attach listeners to new buttons
        content_div.querySelectorAll('.sgdb-mini-unfollow').forEach(btn =>
        {
            btn.addEventListener('click', async (event) =>
            {
                const id = event.target.dataset.id;
                const follows = await load_follows();
                delete follows[id];
                await save_follows(follows);

                // Sync profile button if visible
                const current_id = get_current_user_id();
                if (current_id === id)
                {
                    const profile_btn = document.querySelector('.sgdb-follow-btn');
                    if (profile_btn)
                    {
                        profile_btn.innerHTML = ICON_HEART_OUTLINE;
                        profile_btn.classList.remove('active');
                        profile_btn.title = 'Follow User';
                    }
                }
            });
        });
    }


    // V- MANAGER MODAL -V

    // Open the full-screen management modal.
    async function open_manager()
    {
        const modal = document.getElementById('sgdb-modal-backdrop');
        modal.classList.add('active');

        const follows = await load_follows();
        const ids = Object.keys(follows);

        let html_content = `<h2>Manage Followed Users</h2>`;

        if (ids.length === 0)
        {
            html_content += `<p style="color:#888">You are not following anyone.</p>`;
        }
        else
        {
            html_content += `<div class="sgdb-follow-list" style="margin-top:15px;">`;
            ids.forEach(id =>
            {
                const user = follows[id];
                html_content += `
                    <div class="sgdb-follow-item" data-id="${id}" style="display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid #333;">
                        <div style="display:flex; align-items:center; gap:10px;">
                            <img src="${user.avatar}" style="width:32px; height:32px; border-radius:4px;">
                            <a href="/profile/${id}" style="color:#eee; text-decoration:none;">${user.name}</a>
                        </div>
                        <button class="sgdb-unfollow-btn" style="background:#333; color:white; border:none; padding:5px 10px; cursor:pointer;">Unfollow</button>
                    </div>
                `;
            });
            html_content += `</div>`;
            html_content += `<button id="sgdb-delete-all" style="margin-top:20px; background:none; border:none; color:#888; cursor:pointer; text-decoration:underline;">Delete All Follows</button>`;
        }
        html_content += `<button id="sgdb-close-manager" style="margin-top:20px; background:#333; color:white; border:none; padding:10px 20px; cursor:pointer; float:right;">Close</button>`;

        const content = document.getElementById('sgdb-modal-content');
        content.innerHTML = html_content;

        // Modal Event Listeners
        document.getElementById('sgdb-close-manager').onclick = () => modal.classList.remove('active');

        document.querySelectorAll('.sgdb-unfollow-btn').forEach(btn =>
        {
            btn.addEventListener('click', async (event) =>
            {
                const item = event.target.closest('.sgdb-follow-item');
                const id = item.dataset.id;
                const follows = await load_follows();
                delete follows[id];
                await save_follows(follows);
                open_manager(); // Re-render modal

                // Sync profile button
                const current_id = get_current_user_id();
                if (current_id === id)
                {
                    const profile_btn = document.querySelector('.sgdb-follow-btn');
                    if (profile_btn)
                    {
                        profile_btn.innerHTML = ICON_HEART_OUTLINE;
                        profile_btn.classList.remove('active');
                    }
                }
            });
        });

        const delete_all = document.getElementById('sgdb-delete-all');
        if(delete_all)
        {
            delete_all.addEventListener('click', async () =>
            {
                if(confirm("Delete all follows?"))
                {
                    await GM.deleteValue(STORAGE_KEY);
                    const data = await load_follows();
                    update_dropdown_content(data);
                    modal.classList.remove('active');
                }
            });
        }
    }


    // V- INITIALIZATION & OBSERVER -V

    let last_url = location.href;

    async function init()
    {
        const follows = await load_follows();
        await render_profile_button();
        render_nav_trigger();
        update_dropdown_content(follows);
    }

    // Ensure Modal Container exists
    if (!document.getElementById('sgdb-modal-backdrop'))
    {
        const modal_html = `
            <div id="sgdb-modal-backdrop">
                <div id="sgdb-modal-content"></div>
            </div>`;
        document.body.insertAdjacentHTML('beforeend', modal_html);
    }

    GM_registerMenuCommand("Manage Followed Users", open_manager);

    init();

    // Strategy: Watch the main app container.
    // If the URL changes, we re-initialize (full reset).
    // If only the DOM content changes (filtering), we only re-render the button.
    function setup_observer(target)
    {
        let raf = null;

        const observer = new MutationObserver(() =>
        {
            if (raf)
            {
                return;
            }

            raf = requestAnimationFrame(() =>
            {
                const current_url = location.href;

                if (current_url !== last_url)
                {
                    last_url = current_url;

                    // Close modals/dropdowns on navigation
                    const modal = document.getElementById('sgdb-modal-backdrop');
                    if (modal) modal.classList.remove('active');

                    const dropdown = document.getElementById('sgdb-dropdown');
                    if (dropdown) dropdown.classList.remove('is-open');

                    // Full re-init
                    init();
                }
                else
                {
                    // DOM Change (Same URL): Ensure UI persists
                    render_profile_button();
                    render_nav_trigger();
                }

                raf = null;
            });
        });

        observer.observe(target, { childList: true, subtree: true });
    }

    // Start Observer
    const app_root = document.querySelector('#render-me-uwu');

    if (app_root)
    {
        setup_observer(app_root);
    }
    else
    {
        const root_observer = new MutationObserver((mutations, obs) =>
        {
            const root = document.querySelector('#render-me-uwu');
            if (root)
            {
                obs.disconnect();
                setup_observer(root);
                init();
            }
        });
        root_observer.observe(document.body, { childList: true, subtree: true });
    }

})();