Speedrun.com JSON UI Viewer

Renders exported Speedrun.com JSON files as a readable UI with flags

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

Advertisement:

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

Advertisement:

// ==UserScript==
// @name         Speedrun.com JSON UI Viewer
// @namespace    local
// @version      1.1
// @description  Renders exported Speedrun.com JSON files as a readable UI with flags
// @author       local
// @license      MIT
// @match        file:///*speedrun*.json*
// @match        *://*/*speedrun*.json*
// @run-at       document-end
// @grant        none
// ==/UserScript==

/*
MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files, to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
*/

(function () {
    'use strict';

    function readJsonFromPage() {
        const text =
            document.body?.innerText ||
            document.querySelector('pre')?.innerText ||
            '';

        try {
            const json = JSON.parse(text);
            return Array.isArray(json) ? json : null;
        } catch (e) {
            return null;
        }
    }

    function el(tag, className, text) {
        const node = document.createElement(tag);

        if (className) node.className = className;
        if (text !== undefined) node.textContent = text;

        return node;
    }

    function link(url, text, className) {
        if (!url) return el('span', className, text || '');

        const a = document.createElement('a');
        a.href = url;
        a.textContent = text || url;
        a.className = className || '';
        a.target = '_blank';
        a.rel = 'noopener noreferrer';

        return a;
    }

    function flag(url, name) {
        if (!url) return null;

        const img = document.createElement('img');

        img.src = url;
        img.alt = name || '';
        img.title = name || '';
        img.className = 'src-flag';

        return img;
    }

    function addStyles() {
        const style = document.createElement('style');

        style.textContent = `
            :root {
                color-scheme: dark;
                font-family: Inter, Arial, sans-serif;
                background: #111318;
                color: #e8e8e8;
            }

            body {
                margin: 0;
                background: #111318;
                color: #e8e8e8;
            }

            .src-viewer {
                max-width: 1200px;
                margin: 0 auto;
                padding: 24px;
            }

            .src-title {
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 16px;
                margin-bottom: 20px;
            }

            .src-title h1 {
                margin: 0;
                font-size: 24px;
            }

            .src-count {
                color: #aaa;
                font-size: 14px;
            }

            .src-table {
                width: 100%;
                border-collapse: collapse;
                overflow: hidden;
                border-radius: 10px;
                background: #191c22;
                box-shadow: 0 0 0 1px #2a2e38;
            }

            .src-table th,
            .src-table td {
                padding: 10px 12px;
                border-bottom: 1px solid #2a2e38;
                text-align: left;
                vertical-align: top;
                font-size: 14px;
            }

            .src-table th {
                background: #222631;
                color: #f1f1f1;
                font-weight: 700;
                position: sticky;
                top: 0;
                z-index: 2;
            }

            .src-table tr:hover td {
                background: #20242c;
            }

            .src-rank {
                width: 56px;
                text-align: center;
                color: #ffd36a;
                font-weight: 700;
            }

            .src-time {
                color: #7dd3fc;
                font-weight: 700;
                white-space: nowrap;
            }

            .src-muted {
                color: #aaa;
            }

            .src-link {
                color: #8ab4ff;
                text-decoration: none;
            }

            .src-link:hover {
                text-decoration: underline;
            }

            .src-inline {
                display: inline-flex;
                align-items: center;
                gap: 6px;
            }

            .src-flag {
                height: 12px;
                width: auto;
                border-radius: 2px;
                flex: none;
            }

            .src-level-card {
                margin-bottom: 14px;
                padding: 16px;
                border-radius: 10px;
                background: #191c22;
                box-shadow: 0 0 0 1px #2a2e38;
            }

            .src-level-header {
                margin-bottom: 12px;
                font-size: 18px;
                font-weight: 700;
            }

            .src-places {
                display: grid;
                grid-template-columns: repeat(3, minmax(0, 1fr));
                gap: 12px;
            }

            .src-place {
                padding: 12px;
                border-radius: 8px;
                background: #222631;
            }

            .src-place-title {
                margin-bottom: 6px;
                color: #ffd36a;
                font-weight: 700;
            }

            .src-vars {
                display: flex;
                flex-wrap: wrap;
                gap: 6px;
            }

            .src-var {
                padding: 3px 7px;
                border-radius: 999px;
                background: #2a2e38;
                color: #ddd;
                font-size: 12px;
                white-space: nowrap;
            }

            @media (max-width: 800px) {
                .src-viewer {
                    padding: 12px;
                }

                .src-table {
                    display: block;
                    overflow-x: auto;
                }

                .src-places {
                    grid-template-columns: 1fr;
                }
            }
        `;

        document.head.appendChild(style);
    }

    function formatVariableName(key) {
        return key
            .replace(/_/g, ' ')
            .replace(/\b\w/g, char => char.toUpperCase());
    }

    function renderVariables(item) {
        const box = el('div', 'src-vars');
        const variables = item.variables || {};

        Object.keys(variables).forEach(key => {
            box.appendChild(
                el('span', 'src-var', `${formatVariableName(key)}: ${variables[key]}`)
            );
        });

        return box;
    }

    function inlineWithFlag(flagUrl, flagName, child) {
        const box = el('span', 'src-inline');
        const img = flag(flagUrl, flagName);

        if (img) box.appendChild(img);
        box.appendChild(child);

        return box;
    }

    function makePlayerCell(item) {
        const td = el('td');
        const player = link(item.user_link, item.player || '', 'src-link');

        td.appendChild(
            inlineWithFlag(item.user_flag_url, item.user_flag_name, player)
        );

        return td;
    }

    function makePlatformCell(item) {
        const td = el('td');
        const text = el('span', '', item.platform || '');

        td.appendChild(
            inlineWithFlag(item.platform_flag_url, item.platform_flag_name, text)
        );

        return td;
    }

    function makeLinkedCell(url, text, className) {
        const td = el('td');

        if (url) {
            td.appendChild(link(url, text, className || 'src-link'));
        } else {
            td.appendChild(el('span', className || '', text));
        }

        return td;
    }

    function renderNormalLeaderboard(data) {
        const table = el('table', 'src-table');

        const thead = el('thead');
        const headerRow = el('tr');

        ['#', 'Player', 'Time', 'Date', 'Platform', 'Variables', 'Run'].forEach(name => {
            headerRow.appendChild(el('th', '', name));
        });

        thead.appendChild(headerRow);
        table.appendChild(thead);

        const tbody = el('tbody');

        data.forEach(item => {
            const tr = el('tr');

            tr.appendChild(el('td', 'src-rank', item.rank || ''));
            tr.appendChild(makePlayerCell(item));
            tr.appendChild(makeLinkedCell(item.run_link, item.time || '', 'src-time'));
            tr.appendChild(el('td', 'src-muted', item.date || ''));
            tr.appendChild(makePlatformCell(item));

            const varsCell = el('td');
            varsCell.appendChild(renderVariables(item));
            tr.appendChild(varsCell);

            const runCell = el('td');
            runCell.appendChild(link(item.run_link, item.run_link ? 'Open run' : '', 'src-link'));
            tr.appendChild(runCell);

            tbody.appendChild(tr);
        });

        table.appendChild(tbody);
        return table;
    }

    function renderPlace(placeData, label) {
        const box = el('div', 'src-place');

        box.appendChild(el('div', 'src-place-title', label));

        const player = el('div');
        player.appendChild(
            inlineWithFlag(
                placeData.user_flag_url,
                placeData.user_flag_name,
                link(placeData.user_link, placeData.player || 'Unknown', 'src-link')
            )
        );
        box.appendChild(player);

        box.appendChild(el('div', 'src-time', placeData.time || ''));

        if (placeData.run_link) {
            const run = el('div');
            run.appendChild(link(placeData.run_link, 'Open run', 'src-link'));
            box.appendChild(run);
        }

        return box;
    }

    function renderLevelTop(data) {
        const wrapper = el('div');

        data.forEach(item => {
            const card = el('div', 'src-level-card');

            const header = el('div', 'src-level-header');
            header.appendChild(link(item.level_link, item.level || 'Unknown level', 'src-link'));
            card.appendChild(header);

            const places = el('div', 'src-places');

            places.appendChild(renderPlace(item.first || {}, '1st place'));
            places.appendChild(renderPlace(item.second || {}, '2nd place'));
            places.appendChild(renderPlace(item.third || {}, '3rd place'));

            card.appendChild(places);
            wrapper.appendChild(card);
        });

        return wrapper;
    }

    function render(data) {
        document.body.innerHTML = '';
        document.title = 'Speedrun.com JSON Viewer';

        addStyles();

        const app = el('div', 'src-viewer');

        const title = el('div', 'src-title');
        title.appendChild(el('h1', '', 'Speedrun.com JSON Viewer'));
        title.appendChild(el('div', 'src-count', `${data.length} rows`));

        app.appendChild(title);

        if (data[0]?.type === 'level_top') {
            app.appendChild(renderLevelTop(data));
        } else {
            app.appendChild(renderNormalLeaderboard(data));
        }

        document.body.appendChild(app);
    }

    const data = readJsonFromPage();

    if (!data) return;

    render(data);
})();