Backloggd RetroAchievements Integration

Integrates RetroAchievements stats directly into Backloggd profiles and game pages.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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