Steam Game Card Badge Enhancer

Enhances Steam game card pages with cached badge information from steamsets.com, highlighting foils with an animation and showing levels on separate lines, inserted after a specific element.

// ==UserScript==
// @name         Steam Game Card Badge Enhancer
// @namespace    https://github.com/encumber
// @version      1.4
// @description  Enhances Steam game card pages with cached badge information from steamsets.com, highlighting foils with an animation and showing levels on separate lines, inserted after a specific element.
// @author       Nitoned
// @match        https://steamcommunity.com/*/gamecards/*
// @grant        GM_xmlhttpRequest
// @connect      api.steamsets.com
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const STEAMSETS_API_KEY = ''; // Get api key from https://steamsets.com/settings/developer-apps
    const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
    const DB_NAME = 'SteamGameCardBadgeCache';
    const DB_VERSION = 2;
    const STORE_NAME = 'gameCardBadges';
    // Selector for the element AFTER which we want to insert the badge container
    const INSERT_AFTER_SELECTOR = '#responsive_page_template_content > div.pagecontent > div.maincontent > div > div.badge_row_inner > div:nth-child(5)';
    // ---------------------

    // --- Foil Animation Style ---
    const foilStyle = `
        .steam-badge-item.foil {
            position: relative; /* Needed for absolute positioning of the pseudo-element */
            overflow: hidden;   /* Hide the overflow of the shine effect */
            background-color: #222; /* Darker background for contrast */
            border: 1px solid rgba(255, 255, 255, 0.1); /* Subtle border */
            box-shadow: 0 0 10px rgba(255, 255, 245, 0.1); /* Subtle glow */
        }

        .steam-badge-item.foil::before {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: linear-gradient(
                45deg,
                rgba(255, 255, 255, 0) 0%, /* Start transparent */
                rgba(255, 255, 255, 0.2) 0%, /* Shine effect */
                rgba(255, 255, 255, 0) 0%  /* End transparent */
            );
            background-size: 200% 100%; /* Make the gradient wider than the element */
            animation: shine 3s linear infinite; /* Adjust animation duration as needed */
        }

        @keyframes shine {
            0% {
                background-position: -200% center; /* Start the gradient off-screen to the left */
            }
            100% {
                background-position: 200% center; /* Move the gradient off-screen to the right */
            }
        }
    `;
    // ----------------------------

    let db;
    let dbPromise = null;

    // Function to add the foil animation style to the head
    function addFoilStyle() {
        const styleElement = document.createElement('style');
        styleElement.type = 'text/css';
        styleElement.innerHTML = foilStyle;
        document.head.appendChild(styleElement);
    }


    // Function to open or create the IndexedDB database
    function openDatabase() {
        if (dbPromise) {
            return dbPromise;
        }

        dbPromise = new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, DB_VERSION);

            request.onupgradeneeded = function(event) {
                console.log('IndexedDB: Upgrade needed', event.oldVersion, '->', event.newVersion);
                db = event.target.result;
                try {
                    if (event.oldVersion < 1) {
                         console.log('IndexedDB: Creating object store:', STORE_NAME);
                         db.createObjectStore(STORE_NAME, { keyPath: 'appId' });
                    }
                    // Add any future migrations here for higher versions.

                } catch (e) {
                     console.error('IndexedDB: Error during upgrade:', e);
                     reject(e);
                }
            };

            request.onsuccess = function(event) {
                db = event.target.result;
                console.log('IndexedDB: Database opened successfully');
                db.onclose = function() {
                    console.log('IndexedDB: Database connection closed unexpectedly.');
                    db = null;
                    dbPromise = null;
                };
                db.onerror = function(err) {
                     console.error('IndexedDB: Unhandled database error:', err);
                };
                resolve(db);
            };

            request.onerror = function(event) {
                console.error('IndexedDB: Error opening database:', event.target.error);
                reject('IndexedDB error: ' + (event.target.error ? event.target.error.message : 'Unknown error'));
            };
             request.onblocked = function() {
                console.warn('IndexedDB: Database connection blocked. Close other tabs with this page.');
            };
        });

        return dbPromise;
    }

    // Function to get cached data for a specific App ID
    async function getCachedBadgeData(appId) {
        try {
            await openDatabase();

            if (!db) {
                 console.warn('IndexedDB: Database not available for get.');
                 return null;
            }

            const transaction = db.transaction([STORE_NAME], 'readonly');
            const store = transaction.objectStore(STORE_NAME);

            return new Promise((resolve, reject) => {
                 const request = store.get(appId);

                 request.onsuccess = function(event) {
                     const cachedData = event.target.result;
                     if (cachedData) {
                         const now = Date.now();
                         if (now - cachedData.timestamp < CACHE_DURATION_MS) {
                             console.log('IndexedDB: Found fresh cache for', appId);
                             resolve(cachedData.data);
                         } else {
                             console.log('IndexedDB: Cache for', appId, 'is stale.');
                             resolve(null);
                         }
                     } else {
                         console.log('IndexedDB: No cache found for', appId);
                         resolve(null);
                     }
                 };

                 request.onerror = function(event) {
                     console.error('IndexedDB: Error getting cached data for', appId, ':', event.target.error);
                     // Resolve with null on error so the script doesn't stop here
                     resolve(null);
                 };
            });
        } catch (error) {
             console.error('IndexedDB: Error in getCachedBadgeData:', error);
             // Resolve with null on error so the script doesn't stop here
             return null;
        }
    }

    // Function to store badge data in IndexedDB
    async function cacheBadgeData(appId, data) {
         try {
            await openDatabase();

            if (!db) {
                 console.warn('IndexedDB: Database not available for cache.');
                 return;
            }

            const transaction = db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);

            const dataToStore = {
                appId: appId,
                data: data,
                timestamp: Date.now()
            };

            return new Promise((resolve, reject) => {
                 const request = store.put(dataToStore);

                 request.onsuccess = function() {
                     console.log('IndexedDB: Cached data for', appId);
                     resolve();
                 };

                 request.onerror = function(event) {
                     console.error('IndexedDB: Error caching data for', appId, ':', event.target.error);
                     // Resolve on error so the script doesn't stop here
                     resolve();
                 };
            });
         } catch (error) {
              console.error('IndexedDB: Error in cacheBadgeData:', error);
              // Resolve on error so the script doesn't stop here
         }
    }


    function fetchBadgeDataFromApi(appId) {
        console.log('Fetching badge data from API for App ID:', appId);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.steamsets.com/v1/app.listBadges',
                headers: {
                    'Content-Type': 'application/json',
                    Authorization: `Bearer ${STEAMSETS_API_KEY}`
                },
                data: JSON.stringify({
                    appId: appId
                }),
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const data = JSON.parse(response.responseText);
                            if (data && data.badges) {
                                resolve(data.badges);
                            } else {
                                reject(new Error('API response is missing the "badges" array.'));
                            }
                        } catch (e) {
                            reject(new Error('Error parsing API response: ' + e));
                        }
                    } else {
                        reject(new Error(`API request failed with status ${response.status}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error('Error fetching badge data: ' + (error.statusText || 'Unknown network error')));
                }
            });
        });
    }

    function displayBadges(badges, appId) {
        const insertAfterElement = document.querySelector(INSERT_AFTER_SELECTOR);

        if (!insertAfterElement) {
            console.error(`Could not find the insertion point element using selector: ${INSERT_AFTER_SELECTOR}`);
            return;
        }

        // Find the parent element of the insertion point
        const parentElement = insertAfterElement.parentElement;
         if (!parentElement) {
             console.error('Could not find the parent element of the insertion point.');
             return;
         }


        // Remove any previously added badge containers and adjacent clearing divs
        // We search for elements immediately following the insertAfterElement
        let nextSibling = insertAfterElement.nextElementSibling;
        while(nextSibling && (nextSibling.classList.contains('game_cards_clear') || nextSibling.classList.contains('steam-badge-container'))) {
             let temp = nextSibling.nextElementSibling;
             nextSibling.remove();
             nextSibling = temp;
        }


        const clearDivBefore = document.createElement('div');
        clearDivBefore.classList.add('game_cards_clear');
        clearDivBefore.style.clear = 'both';

        const badgeContainer = document.createElement('div');
        badgeContainer.classList.add('steam-badge-container');
        badgeContainer.style.marginTop = '20px';
        badgeContainer.style.width = '1000px'; // Set width to 1000px
        badgeContainer.style.marginLeft = '30px'; // Add left margin of 100px
        badgeContainer.style.boxSizing = 'border-box';
        badgeContainer.style.display = 'flex'; // Use flexbox to arrange items
        badgeContainer.style.flexWrap = 'wrap'; // Allow items to wrap to the next line
        badgeContainer.style.gap = '20px'; // Add space between badge items

        const sortedBadges = badges.sort((a, b) => {
            if (a.isFoil === b.isFoil) {
                return a.baseLevel - b.baseLevel;
            }
            return a.isFoil ? 1 : -1;
        });

        sortedBadges.forEach(badge => {
            const badgeElement = document.createElement('div');
            badgeElement.classList.add('steam-badge-item');
            badgeElement.style.textAlign = 'center';
            badgeElement.style.verticalAlign = 'top';
            badgeElement.style.flex = '0 0 auto'; // Prevent items from growing/shrinkings
            badgeElement.style.width = '120px'; // Set a fixed width for each badge item
             badgeElement.style.padding = '5px'; // Added padding
             badgeElement.style.borderRadius = '5px'; // Added border radius


            // Apply foil highlighting styles using the 'foil' class
            if (badge.isFoil) {
                badgeElement.classList.add('foil');
                // Remove the previous gold border and background styles
                badgeElement.style.border = '';
                badgeElement.style.backgroundColor = '';
            } else {
                // Ensure non-foil badges have a default background
                 badgeElement.style.backgroundColor = '#1a1a1a'; // Example: a dark background
            }


            // Div for Level
            const badgeLevel = document.createElement('div');
            badgeLevel.textContent = `${badge.isFoil ? 'Foil' : `Level ${badge.baseLevel}`}`;
            badgeLevel.style.fontWeight = 'bold';
             if (badge.isFoil) {
                 badgeLevel.style.color = 'gold'; // Keep gold text for foil level
            } else {
                 badgeLevel.style.color = '#ccc'; // Default text color for non-foil
            }
            badgeElement.appendChild(badgeLevel);

            // Div for Name
            const badgeName = document.createElement('div');
            badgeName.textContent = badge.name;
            badgeName.style.fontWeight = 'normal';
            badgeName.style.fontSize = '0.9em';
            badgeName.style.whiteSpace = 'nowrap'; // Prevent name from wrapping
            badgeName.style.overflow = 'hidden'; // Hide overflow text
            badgeName.style.textOverflow = 'ellipsis'; // Show ellipsis for overflow
            badgeName.style.color = '#ccc'; // Default text color
            badgeElement.appendChild(badgeName);


            const badgeImage = document.createElement('img');
            badgeImage.src = `https://cdn.fastly.steamstatic.com/steamcommunity/public/images/items/${appId}/${badge.badgeImage}`;
            badgeImage.alt = badge.name;
            badgeImage.style.maxWidth = '100px';
            badgeImage.style.height = 'auto';
            badgeImage.style.display = 'block';
            badgeImage.style.margin = '5px auto';
            badgeElement.appendChild(badgeImage);

            const badgeScarcity = document.createElement('div');
            badgeScarcity.textContent = `Scarcity: ${badge.scarcity}`;
            badgeScarcity.style.fontSize = '0.8em';
            badgeScarcity.style.color = '#888'; // Grey out scarcity text
            badgeElement.appendChild(badgeScarcity);

            badgeContainer.appendChild(badgeElement);
        });

        // Insert the first clearing div after the specified element
        parentElement.insertBefore(clearDivBefore, insertAfterElement.nextSibling);
        // Insert the badge container after the first clearing div
        clearDivBefore.parentNode.insertBefore(badgeContainer, clearDivBefore.nextSibling);

         // Add the second clearing div after the badge container
        const clearDivAfter = document.createElement('div');
        clearDivAfter.classList.add('game_cards_clear');
        clearDivAfter.style.clear = 'both';
        badgeContainer.parentNode.insertBefore(clearDivAfter, badgeContainer.nextSibling);
    }

    async function init() {
        // Add the foil style to the head as soon as the script runs
        addFoilStyle();

        const urlParts = window.location.pathname.split('/');
        const gamecardsIndex = urlParts.indexOf('gamecards');
        let appId = null;

        if (gamecardsIndex !== -1 && gamecardsIndex + 1 < urlParts.length) {
            appId = parseInt(urlParts[gamecardsIndex + 1]);
        }

        if (isNaN(appId)) {
            console.error('Could not extract App ID from the URL.');
            return;
        }

        // Wait for the specific element to be present in the DOM before proceeding
        const observer = new MutationObserver((mutations, obs) => {
            const insertAfterElement = document.querySelector(INSERT_AFTER_SELECTOR);
            if (insertAfterElement) {
                obs.disconnect(); // Stop observing once the element is found
                processBadgeData(appId);
            }
        });

        // Start observing the body for changes
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // In case the element is already present on page load
        if (document.querySelector(INSERT_AFTER_SELECTOR)) {
             observer.disconnect(); // Stop observing
             processBadgeData(appId);
        }
    }

    async function processBadgeData(appId) {
        try {
            const cachedBadgeData = await getCachedBadgeData(appId);

            if (cachedBadgeData) {
                console.log('Using cached badge data for App ID:', appId);
                displayBadges(cachedBadgeData, appId);
            } else {
                console.log('Cached data not found or stale for App ID:', appId, '. Fetching from API.');
                const fetchedBadgeData = await fetchBadgeDataFromApi(appId);

                if (fetchedBadgeData && fetchedBadgeData.length > 0) {
                    console.log('Successfully fetched badge data for App ID:', appId);
                    displayBadges(fetchedBadgeData, appId);
                    // Do not await cacheData; let it happen in the background
                    cacheBadgeData(appId, fetchedBadgeData).catch(e => console.error('Failed to cache data:', e));
                    console.log('Attempting to cache badge data for App ID:', appId);
                } else {
                    console.log('No badge data found for this app from API.');
                }
            }
        } catch (error) {
            // Log the error but allow the script to continue if possible
            console.error('Error during badge data processing:', error);
        }
    }


    window.addEventListener('load', init);

})();