Backloggd RetroAchievements Integration

Integrates RetroAchievements stats directly into Backloggd profiles and game pages.

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!)

Advertisement:

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!)

Advertisement:

// ==UserScript==
// @name         Backloggd RetroAchievements Integration
// @namespace    http://tampermonkey.net/
// @version      7.0.0
// @description  Integrates RetroAchievements stats directly into Backloggd profiles and game pages.
// @author       [smvsch]
// @match        *://*.backloggd.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @connect      retroachievements.org
// @connect      vuycmvpcepeqwuujfsdu.supabase.co
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==================== CONFIGURATION ====================
    const CONFIG = {
        PROXY_URL: "https://vuycmvpcepeqwuujfsdu.supabase.co/functions/v1/proxy-handler"
    };
    // =======================================================

    let lastUrl = window.location.href;

    // --- REACIVE INJECTION LOGIC ---
    function attemptInjection(retries = 10) {
        if (retries <= 0) return;

        const isGamePage = window.location.pathname.includes('/games/');
        const isProfilePage = window.location.pathname.startsWith('/u/');

        if (!isGamePage && !isProfilePage) return;
        if (document.getElementById('ra-integration-card')) return;

        let targetArea = document.querySelector('.row.mt-3') || document.querySelector('.card-body') || document.querySelector('main');

        if (targetArea) {
            const raContainer = document.createElement('div');
            raContainer.id = 'ra-integration-card';
            raContainer.style = 'background: #1c2025; border: 1px solid #2b3036; border-radius: 6px; margin: 20px 0; width: 100%; padding: 18px; font-family: inherit; box-sizing: border-box;';
            targetArea.parentNode.insertBefore(raContainer, targetArea.nextSibling);

            if (isProfilePage && getProfileOwner()) {
                handleProfilePage(raContainer, getProfileOwner(), getLoggedUser());
            } else if (isGamePage) {
                handleGamePage(raContainer, getLoggedUser());
            }
        } else {
            setTimeout(() => attemptInjection(retries - 1), 100);
        }
    }

    function getLoggedUser() {
        const userNav = document.querySelector('a.nav-link[href^="/u/"]');
        return userNav ? userNav.getAttribute('href').split('/')[2] : null;
    }

    function getProfileOwner() {
        const path = window.location.pathname;
        return path.startsWith('/u/') ? path.split('/')[2] : null;
    }

    // ==================== ROUTE A: GAME TRACKING ====================
    function handleGamePage(container, loggedUser) {
        const titleElement = document.querySelector('h1') || document.querySelector('.game-page-title');
        if (!titleElement) return;
        const gameTitle = titleElement.innerText.trim();
        const gamePathKey = "MANUAL_ID_" + window.location.pathname.replace(/\/games\//, '').replace(/\//g, '');

        let RA_USER = GM_getValue("RA_USERNAME", "");
        let RA_API_KEY = GM_getValue("RA_API_KEY", "");

        if (!RA_USER || !RA_API_KEY) {
            container.innerHTML = `
                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 8px;">RetroAchievements Global Sync</div>
                <p style="font-size: 12px; color: #9fa6af; margin: 0 0 12px 0;">Link your account once to publish your achievements to the community database seamlessly.</p>
                <div style="display: flex; gap: 10px; max-width: 500px; margin-bottom: 10px;">
                    <input type="text" id="ra-set-user" placeholder="RA Username" style="padding: 6px 12px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px; flex: 1;">
                    <input type="password" id="ra-set-key" placeholder="Web API Key" style="padding: 6px 12px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px; flex: 1;">
                    <button id="ra-save-btn" style="padding: 6px 16px; background: #e91e63; color: #fff; font-weight: 600; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">Link & Publish</button>
                </div>
                <div style="font-size: 11px;"><a href="https://retroachievements.org/controlpanel.php" target="_blank" style="color: #ff4081; text-decoration: none;">🔑 Get your Web API Key here (Keys section) ↗</a></div>
            `;
            document.getElementById('ra-save-btn').addEventListener('click', () => {
                const user = document.getElementById('ra-set-user').value.trim();
                const key = document.getElementById('ra-set-key').value.trim();
                if (user && key && loggedUser) {
                    GM_setValue("RA_USERNAME", user);
                    GM_setValue("RA_API_KEY", key);
                    publishToSupabase(loggedUser, user);
                }
            });
            return;
        }

        container.innerHTML = `
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase;">RetroAchievements Progress</div>
                <div style="display: flex; gap: 12px;">
                    <button id="ra-override-btn" style="background:none; border:none; color:#9fa6af; cursor:pointer; font-size:11px; padding:0; text-decoration: underline;">Manual Link ID</button>
                    <button id="ra-clear-btn" style="background:none; border:none; color:#ff4d4d; cursor:pointer; font-size:11px; padding:0; text-decoration: underline;">Unlink</button>
                </div>
            </div>
            <div id="ra-content" style="font-size: 13px; color: #9fa6af;">Synchronizing achievement indexes...</div>
        `;

        document.getElementById('ra-clear-btn').addEventListener('click', () => { if(confirm("Disconnect account?")) { GM_setValue("RA_USERNAME", ""); GM_setValue("RA_API_KEY", ""); location.reload(); } });
        document.getElementById('ra-override-btn').addEventListener('click', () => {
            let currentManualId = GM_getValue(gamePathKey, "");
            let manualId = prompt("Enter RetroAchievements Game ID manually:", currentManualId);
            if (manualId !== null) { GM_setValue(gamePathKey, manualId.trim()); location.reload(); }
        });

        const progressUrl = `https://retroachievements.org/API/API_GetUserCompletionProgress.php?z=${RA_USER}&y=${RA_API_KEY}&u=${RA_USER}&c=500`;
        fetchRAData(progressUrl, gameTitle, gamePathKey, RA_USER, RA_API_KEY);
    }

    // ==================== ROUTE B: SOCIAL PROFILES ====================
    function handleProfilePage(container, profileOwner, loggedUser) {
        container.innerHTML = `<div id="ra-content" style="font-size: 13px; color: #9fa6af;">Checking network database...</div>`;
        fetchFromSupabase(profileOwner, (raUsername) => {
            if (!raUsername) {
                container.innerHTML = `<div style="font-size:12px; color:#6c757d; font-style:italic;">This user hasn't linked their RetroAchievements profile to the script network yet.</div>`;
                return;
            }
            let MY_USER = GM_getValue("RA_USERNAME", "");
            let MY_API_KEY = GM_getValue("RA_API_KEY", "");
            if (!MY_USER || !MY_API_KEY) {
                container.innerHTML = `<div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 4px;">RetroAchievements Profile</div><div style="font-size:12px; color:#9fa6af;">This profile maps to RA user <strong>${raUsername}</strong>. Link your own account on any game page to see their full arcade showcase stats here!</div>`;
                return;
            }
            const summaryUrl = `https://retroachievements.org/API/API_GetUserSummary.php?z=${MY_USER}&y=${MY_API_KEY}&u=${raUsername}&g=0&a=0`;
            GM_xmlhttpRequest({
                method: "GET",
                url: summaryUrl,
                onload: function(res) {
                    try {
                        let data = JSON.parse(res.responseText);
                        const points = data.Points || 0;
                        const rank = data.Rank ? parseInt(data.Rank).toLocaleString() : "Unranked";
                        const userPic = data.UserPic ? `https://media.retroachievements.org${data.UserPic}` : 'https://retroachievements.org/images/no_avatar.png';
                        container.innerHTML = `
                            <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
                                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase;">RetroAchievements Showcase</div>
                                <a href="https://retroachievements.org/user/${raUsername}" target="_blank" style="color: #ff4081; text-decoration: none; font-size: 12px; font-weight: 600;">View Full Profile ↗</a>
                            </div>
                            <div style="display: flex; gap: 16px; align-items: center;">
                                <img src="${userPic}" style="width: 54px; height: 54px; border-radius: 4px; border: 1px solid #2b3036; background: #111417;">
                                <div style="display: flex; flex: 1; justify-content: space-around; background: #111417; border: 1px solid #2b3036; border-radius: 4px; padding: 10px;">
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">RA Handle</div><div style="font-size: 14px; color: #fff; font-weight: 600;">${raUsername}</div></div>
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">Score</div><div style="font-size: 14px; color: #ffb300; font-weight: 600;">Points: ${points.toLocaleString()}</div></div>
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">Global Rank</div><div style="font-size: 14px; color: #26a65b; font-weight: 600;"># ${rank}</div></div>
                                </div>
                            </div>
                        `;
                    } catch(e) { container.innerHTML = `<div style="font-size:12px; color:#ff4d4d;">Failed to load user statistics panel.</div>`; }
                }
            });
        });
    }

    // ==================== NETWORKING & ENGINE ====================
    function publishToSupabase(backloggdUser, raUser) {
        GM_xmlhttpRequest({
            method: "POST",
            url: `${CONFIG.PROXY_URL}/user_mappings`,
            headers: { "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates" },
            data: JSON.stringify({ backloggd_username: backloggdUser.toLowerCase(), ra_username: raUser }),
            onload: function() { location.reload(); }
        });
    }

    function fetchFromSupabase(backloggdUser, callback) {
        GM_xmlhttpRequest({
            method: "GET",
            url: `${CONFIG.PROXY_URL}/user_mappings?backloggd_username=eq.${backloggdUser.toLowerCase()}&select=ra_username`,
            onload: function(res) {
                try { let data = JSON.parse(res.responseText); callback(data && data.length > 0 ? data[0].ra_username : null); } catch(e) { callback(null); }
            }
        });
    }

    function fetchRAData(url, gameTitle, gamePathKey, user, apiKey) {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function(response) {
                try {
                    let parsed = JSON.parse(response.responseText.trim());
                    let gamesArray = parsed && parsed.Results ? parsed.Results : (parsed && typeof parsed === 'object' ? Object.values(parsed) : []);
                    let savedManualId = GM_getValue(gamePathKey, "");
                    let match = savedManualId ? gamesArray.find(g => g && g.GameID && g.GameID.toString() === savedManualId) : gamesArray.find(g => g && g.Title && cleanTitle(g.Title) === cleanTitle(gameTitle));
                    if (match || savedManualId) { fetchGameBadges(match ? match.GameID : savedManualId, user, apiKey, savedManualId ? true : false); } else { renderManualForm(gameTitle, gamePathKey); }
                } catch (e) { document.getElementById('ra-content').innerHTML = `Local storage index array verification parsed offline.`; }
            }
        });
    }

    function fetchGameBadges(gameId, user, apiKey, isOverridden) {
        GM_xmlhttpRequest({
            method: "GET",
            url: `https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php?z=${user}&y=${apiKey}&u=${user}&g=${gameId}`,
            onload: function(res) {
                try { renderSteamStyleUI(JSON.parse(res.responseText), isOverridden); } catch(e) { document.getElementById('ra-content').innerHTML = `Failed loading target trophy rows.`; }
            }
        });
    }

    function cleanTitle(str) { return str.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); }

    function renderManualForm(gameTitle, gamePathKey) {
        const container = document.getElementById('ra-content');
        const searchUrl = `https://retroachievements.org/search?query=${encodeURIComponent(gameTitle)}`;

        container.innerHTML = `
            <div style="font-size: 13px; color: #9fa6af; margin-bottom: 10px;">
                <strong>Auto-match failed.</strong> Link this game manually:
            </div>
            <div style="display: flex; gap: 8px; margin-bottom: 12px;">
                <input type="text" id="ra-manual-input" placeholder="Paste ID (e.g. 1234)..."
                    style="flex: 1; padding: 6px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px;">
                <button id="ra-manual-save-btn" style="padding: 6px 12px; background: #e91e63; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600;">Link ID</button>
            </div>
            <div style="font-size: 11px; color: #6c757d; line-height: 1.6;">
                1. <a href="${searchUrl}" target="_blank" style="color: #ff4081; text-decoration: underline;">Search for "${gameTitle}" on RA</a><br>
                2. Click your game from the search results.<br>
                3. Copy the ID number from the URL address bar.<br>
                4. Paste that ID above and click "Link ID".
            </div>
        `;

        document.getElementById('ra-manual-save-btn').addEventListener('click', () => {
            const inputId = document.getElementById('ra-manual-input').value.trim();
            if(inputId) {
                GM_setValue(gamePathKey, inputId);
                location.reload();
            }
        });
    }

    function renderSteamStyleUI(gameData, isOverridden) {
        const achList = Object.values(gameData.Achievements || {});
        const maxAch = achList.length;
        const unlockedList = achList.filter(a => a.DateEarned || a.DateEarnedHardcore);
        const awarded = unlockedList.length;
        const pct = maxAch > 0 ? ((awarded / maxAch) * 100).toFixed(1) : "0.0";
        const mainColor = (maxAch > 0 && awarded === maxAch) ? '#26a65b' : '#e91e63';
        let badgesToShow = [...unlockedList].sort((a, b) => new Date(b.DateEarnedHardcore || b.DateEarned || 0) - new Date(a.DateEarnedHardcore || a.DateEarned || 0));
        if (badgesToShow.length < 6) badgesToShow = badgesToShow.concat(achList.filter(a => !a.DateEarned && !a.DateEarnedHardcore).slice(0, 6 - badgesToShow.length));
        badgesToShow = badgesToShow.slice(0, 6);
        let badgesHTML = '<div style="display: flex; gap: 10px; margin-top: 8px;">';
        badgesToShow.forEach(ach => {
            const isUnlocked = ach.DateEarned || ach.DateEarnedHardcore;
            const imgStyle = isUnlocked ? 'width:100%; height:100%; object-fit:cover;' : 'width:100%; height:100%; object-fit:cover; filter:grayscale(1) opacity(0.35);';
            badgesHTML += `<div style="position: relative; width: 44px; height: 44px; border-radius: 4px; overflow: hidden; border: 1px solid #2b3036; background: #111417;" title="${ach.Title}"><img src="https://media.retroachievements.org/Badge/${ach.BadgeName}.png" style="${imgStyle}"></div>`;
        });
        badgesHTML += '</div>';
        document.getElementById('ra-content').innerHTML = `
            <div style="display: grid; grid-template-columns: 1fr 240px; gap: 30px; align-items: center;">
                <div>
                    <div style="font-size: 15px; font-weight: 600; color: #fff;">${gameData.Title} <span style="font-size: 12px; color: #9fa6af; font-weight: normal;">(${gameData.ConsoleName || 'System'})</span></div>
                    <div style="margin-top: 10px;"><div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">Trophy Showcase</div>${badgesHTML}</div>
                </div>
                <div>
                    <div style="display: flex; justify-content: space-between; font-size: 12px; color: #fff; font-weight: 600;"><span>Progress</span><span>${awarded}/${maxAch} (${pct}%)</span></div>
                    <div style="background: #111417; border: 1px solid #2b3036; border-radius: 4px; height: 10px; width: 100%; overflow: hidden;"><div style="background: ${mainColor}; height: 100%; width: ${pct}%;"></div></div>
                </div>
            </div>`;
    }

    function init() {
        lastUrl = window.location.href;
        attemptInjection(20);
    }

    window.addEventListener('popstate', () => {
        setTimeout(init, 500);
    });

    const originalPushState = history.pushState;
    history.pushState = function(...args) {
        originalPushState.apply(this, args);
        setTimeout(init, 500);
    };

    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            init();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    init();
})();