SG Train Navigation Assistant

Adds some QoL shortcuts for train navigation on SG!

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         SG Train Navigation Assistant
// @namespace    http://tampermonkey.net/
// @version      2025-09-27
// @description  Adds some QoL shortcuts for train navigation on SG!
// @author       Alpha2749 | SG /user/Alpha2749
// @match        https://www.steamgifts.com/giveaway/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=steamgifts.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';
    /* Fallback default config (DO NOT TOUCH)
       -  If you want to modify your configuration
          please click on your userscript manager
          and click 'Configure Script'
    */
    const defaultConfig = {
        allowOpenScreenshots: true,
        keyBindings: {
            next: "ArrowRight",
            previous: "ArrowLeft",
            screenshots: "ArrowUp",
            trailerToggle: "Control",
        }
    };
    let config = loadConfig();
    GM_registerMenuCommand("Configure Script", () => {
        openConfigUI();
    });


    const nextKeywords = ['next', 'forward', 'on', '>', 'cho', '→', 'N E X T', 'ahead', 'future', 'climbing', '↬', 'avanti', 'prossimo', '▶', 'nekst', 'yes', 'go', '➡️', '⏩', '⏭️', '🌜', '👉', 'Forth'];
    const lastKeywords = ['prev', 'back', 'last', '<', 'och', '←', 'B A C K', 'retreat', 'past', 'falling', '↫', 'indietro', 'precedente', '◀', 'previous', 'perv', 'prior', 'no', 'og', '⬅️', '⏪', '⏮️', '🌛', '👈'];

    document.addEventListener("keydown", function (event) {
        const isInputField = ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName);
        const configOpen = document.querySelector("#tm-config-ui");
        if (isInputField || configOpen) return;

        const screenshotsOpen = !document.querySelector(".lightbox.hide");
        if (screenshotsOpen) {
            handleScreenshots(event);
            return;
        }

        if (event.key === config.keyBindings.next) handleNavigation("next");
        if (event.key === config.keyBindings.previous) handleNavigation("previous");
        if (config.allowOpenScreenshots && event.key === config.keyBindings.screenshots) {
            openScreenshots();
        }
    });

    async function handleNavigation(direction) {
        const link = extractLinks(direction) || findLabelledLink(direction) || findLink(direction);
        if (link) {
            showPopup(`Moving ${direction === 'next' ? 'Onward' : 'Backward'}!`);
            window.location.href = link;
        } else {
            showPopup(`Unable to find ${direction} cart. Are you sure you're in a train?`);
        }
    }

    function findLink(direction) {
        var regex = new RegExp((direction === 'next' ? nextKeywords : lastKeywords).join('|'), 'i');
        return Array.from(document.querySelector('.page__description')?.querySelectorAll('a') || []).find(link => {
            const text = link.textContent.trim();
            const url = link.href;
            const isValidURL = url.includes('/giveaway/') && !url.includes('/discussion/') && !url.includes('/user/');
            return isValidURL && regex.test(text);
        })?.href;
    }

    function findLabelledLink(direction) {
        var regex = new RegExp(`^\\s*(?:${(direction === 'next' ? nextKeywords : lastKeywords).join('|')})(?=\\s*:?)`, 'i');
        const container = document.querySelector('.page__description');
        if (!container) return null;

        const lines = container.innerText.split('\n');
        for (const line of lines) {
            if (regex.test(line)) {
                const match = line.match(/(https?:\/\/[^ \n]+)/);
                if (match) {
                    const url = match[1];
                    const isValidURL = url.includes('/giveaway/') && !url.includes('/discussion/') && !url.includes('/user/');
                    if (isValidURL) return url;
                }
            }
        }
        return null;
    }

    function extractLinks(direction) {
        const paragraphs = document.querySelector('.page__description')?.querySelectorAll('p, h1, h2') || [];
        const numbers = Array.from(paragraphs)
        .flatMap(paragraph => [...paragraph.innerText.matchAll(/\d+/g)].map(match => parseInt(match)))
        .filter(Boolean);

        const uniqueNumbers = Array.from(new Set(numbers)).sort((a, b) => a - b);
        if (uniqueNumbers.length === 0) return null;

        let run = null;
        for (let i = 0; i < uniqueNumbers.length; i++) {
            if (i + 1 < uniqueNumbers.length &&
                uniqueNumbers[i + 1] - uniqueNumbers[i] === 2) {
                run = [uniqueNumbers[i], uniqueNumbers[i + 1]];
                break;
            }

            if (i + 2 < uniqueNumbers.length &&
                uniqueNumbers[i + 1] - uniqueNumbers[i] === 1 &&
                uniqueNumbers[i + 2] - uniqueNumbers[i + 1] === 1) {
                run = [uniqueNumbers[i], uniqueNumbers[i + 1], uniqueNumbers[i + 2]];
                break;
            }
        }
        if (!run) return null;

        const targetNum = direction === 'previous' ? run[0] : run[run.length - 1];
        const link = Array.from(document.querySelectorAll('a')).find(
            a => a.textContent.trim() === targetNum.toString()
        );
        return link ? link.href : null;
    }

    function handleScreenshots(event) {
        if (event.key === config.keyBindings.screenshots) {
            const closeBtn = document.querySelector('.lightbox-header-icon--close');
            closeBtn?.click();
            return;
        }

        if (event.key === config.keyBindings.trailerToggle) {
            const imageBtn = document.querySelector('.lightbox-header-icon.fa-camera');
            const videoBtn = document.querySelector('.lightbox-header-icon.fa-video-camera');
            if (!imageBtn || !videoBtn) return;

            const isImageSelected = imageBtn.classList.contains('lightbox-header-icon--selected');
            if (isImageSelected) {
                videoBtn.click();
            } else {
                imageBtn.click();
            }
        }
    }

    function openScreenshots() {
        const screenshotBtn = Array.from(document.querySelectorAll('a[data-ui-tooltip]')).find(el => {
            const tooltipData = el.getAttribute('data-ui-tooltip');
            return tooltipData && JSON.parse(tooltipData).rows.some(row =>
                row.columns.some(column => column.name === 'Screenshots / Videos')
            );
        });
        screenshotBtn?.click();
    }

    function showPopup(message) {
        const popup = document.createElement('div');
        popup.style.cssText = `
            position: fixed;
            bottom: 20px;
            right: 20px;
            padding: 10px;
            background-color: rgba(0, 0, 0, 0.8);
            color: white;
            border-radius: 5px;
            z-index: 999999;
            opacity: 0;
            transform: translateY(20px);
            transition: opacity 0.2s, transform 0.3s;
        `;
        popup.textContent = message;
        document.body.appendChild(popup);

        requestAnimationFrame(() => {
            popup.style.opacity = '1';
            popup.style.transform = 'translateY(0)';
        });

        setTimeout(() => {
            popup.style.opacity = '0';
            popup.style.transform = 'translateY(20px)';
            setTimeout(() => {
                document.body.removeChild(popup);
            }, 300);
        }, 2000);
    }


    // Config stuff
    function loadConfig() {
        const saved = GM_getValue("config", {});
        return {
            ...defaultConfig,
            ...saved,
            keyBindings: {
                ...defaultConfig.keyBindings,
                ...(saved.keyBindings || {})
            }
        };
    }

    function saveConfig(cfg) {
        GM_setValue("config", cfg);
    }

    let currentClosePopupHandler = null;
    function openConfigUI() {
        const existing = document.querySelector("#tm-config-ui");
        if (existing) {
            existing.remove();
            return;
        }

        const panel = document.createElement("div");
        panel.id = "tm-config-ui";
        panel.style.position = "fixed";
        panel.style.top = "40px";
        panel.style.right = "40px";
        panel.style.zIndex = "999999";
        panel.style.background = "#fff";
        panel.style.color = "#333";
        panel.style.padding = "20px";
        panel.style.border = "1px solid #ccc";
        panel.style.borderRadius = "8px";
        panel.style.fontFamily = "Segoe UI, sans-serif";
        panel.style.minWidth = "280px";
        panel.style.boxShadow = "0 4px 16px rgba(0,0,0,0.2)";

        panel.innerHTML = `
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
            <h3 style="margin:0;font-size:16px;color:#444;">TrainNavAssist Config</h3>
            <span id="cfg-close" style="cursor:pointer;font-size:16px;color:#999;">✕</span>
        </div>

        <label style="display:block;margin-bottom:10px;">
            <strong>Next Key:</strong><br>
            <input type="text" id="cfg-next" value="${config.keyBindings.next}" readonly
                style="width:100%;padding:6px 8px;margin-top:4px;border:1px solid #ccc;border-radius:4px;">
        </label>

        <label style="display:block;margin-bottom:10px;">
            <strong>Previous Key:</strong><br>
            <input type="text" id="cfg-prev" value="${config.keyBindings.previous}" readonly
                style="width:100%;padding:6px 8px;margin-top:4px;border:1px solid #ccc;border-radius:4px;">
        </label>

        <label style="display: block; margin-bottom: 16px;">
            <strong>Media Keys:</strong><br>
            Open/ Close Screenshots:<br>
            <label style="display: flex; width: 100%;">
                <input type="text" id="cfg-scr" value="${config.keyBindings.screenshots}" readonly
                    style="padding: 6px 8px; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; margin-right: 8px;">
                <input type="checkbox" id="cfg-screenshots" style="width: 48px;" ${config.allowOpenScreenshots ? "checked" : ""}>
            </label>
            Toggle Images/Videos:<br>
            <input type="text" id="cfg-tra" value="${config.keyBindings.trailerToggle}" readonly
                    style="padding: 6px 8px; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; margin-right: 8px;">
        </label>

        <div style="display:flex;gap:8px;justify-content:flex-end;">
            <button id="cfg-reset" style="
                background:#eee;border:1px solid #ccc;border-radius:4px;
                padding:6px 12px;cursor:pointer;font-size:13px;
            ">Reset to Default</button>
        </div>
    `;

        document.body.appendChild(panel);

        panel.querySelector("#cfg-screenshots").addEventListener("change", (e) => {
            config.allowOpenScreenshots = e.target.checked;
            showPopup("Screenshot Hotkey " + (config.allowOpenScreenshots ? 'ON' : 'OFF'));
            saveConfig(config);
        });

        function bindKeyCapture(input, action) {
            input.addEventListener("focus", () => {
                input.value = "Press a key...";
            });
            input.addEventListener("keydown", (e) => {
                e.preventDefault();
                if (e.key === "Escape") {
                    input.value = config.keyBindings[action];
                    input.blur();
                    return;
                }
                input.value = e.key;
                config.keyBindings[action] = e.key;
                saveConfig(config);
                showPopup("Config Saved");
                input.blur();
            });
        }

        bindKeyCapture(panel.querySelector("#cfg-next"), "next");
        bindKeyCapture(panel.querySelector("#cfg-prev"), "previous");
        bindKeyCapture(panel.querySelector("#cfg-scr"), "screenshots");
        bindKeyCapture(panel.querySelector("#cfg-tra"), "trailerToggle");

        panel.querySelector("#cfg-close").onclick = closePopup;
        panel.querySelector("#cfg-reset").onclick = () => {
            config = { ...defaultConfig };
            saveConfig(config);
            showPopup("Config reset to defaults");
            closePopup();
        };

        setTimeout(() => {
            document.addEventListener("click", closePopupHandler);
        }, 0);

        function closePopupHandler(event) {
            if (!panel.contains(event.target)) {
                closePopup();
            }
        }

        function closePopup() {
            document.removeEventListener("click", closePopupHandler);
            panel.remove();
            return;
        }
    }
})();