MJJBOX 增强

为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。

Ajankohdalta 6.9.2025. Katso uusin versio.

// ==UserScript==
// @name         MJJBOX 增强
// @description  为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。
// @description:en Adds features like Quick Bookmarking, Topic Time Display, Precise Reply Time, and Open in New Tab to MJJBox.
// @description:zh-CN 为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。
// @version      0.2.0
// @author       Zz (Modified by Gemini)
// @match        https://mjjbox.com/*
// @icon         https://www.google.com/s2/favicons?domain=mjjbox.com
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @namespace    http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';
    var settings = {};

    const settingsConfig = {
        class_label_topic: "💠 话题内容相关:",
        quick_mark: { type: 'checkbox', label: '快速收藏 ', default: true, style: '', info: '在帖子上增加一个⭐用于快速收藏到书签' },
        show_floor_time: { type: 'checkbox', label: '更精确的回复时间', default: true, style: '', info: '帖子的回复时间改为绝对时间并精确到分钟' },

        class_label_list: "💠 话题列表相关:",
        show_up_time: { type: 'checkbox', label: '显示话题时间', default: true, style: '', info: '话题列表的帖子显示创建/更新时间,老的帖子会褪色泛黄' },

        class_label_all: "💠 通用:",
        open_in_new: { type: 'checkbox', label: '新标签页打开', default: false, style: '', info: '让所有链接默认从新标签页打开' },

        class_label_end: "",
    };

    // Load settings from storage
    Object.keys(settingsConfig).forEach(key => {
        if (typeof settingsConfig[key] === 'object') {
            settings[key] = GM_getValue(key, settingsConfig[key].default);
        }
    });

    // --- Individual Menu Commands ---
    // Register a toggle command for each setting in the Tampermonkey menu
    Object.keys(settingsConfig).forEach(key => {
        const config = settingsConfig[key];
        if (typeof config === 'object' && config.type === 'checkbox') {
            const isEnabled = settings[key];
            const label = `${isEnabled ? '✅' : '❌'} ${config.label.trim()}`;

            GM_registerMenuCommand(label, () => {
                GM_setValue(key, !isEnabled);
                window.location.reload();
            });
        }
    });
    GM_registerMenuCommand('⚙️ 打开详细设置面板 (Open Settings Panel)', openSettings);


    // --- Settings Panel ---
    function openSettings() {
        if (document.querySelector('#mjjbox-custom-setting')) {
            return;
        }
        const shadow = document.createElement('div');
        shadow.style = `position: fixed; top: 0; left: 0; z-index: 8888; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.6);`;
        const panel = document.createElement('div');
        panel.style = `max-width: calc(100% - 100px); width: max-content; position: fixed; top: 50%; left: 50%; z-index: 9999; transform: translate(-50%, -50%); background-color: var(--secondary); color: var(--primary); padding: 15px 25px; box-shadow: 0 0 15px rgba(0, 0, 0, 0.7); max-height: calc(95vh - 40px); overflow-y: auto; border-radius: 8px;`;
        panel.id = "mjjbox-custom-setting";
        let html = `
     <style type="text/css">
       #mjjbox-custom-setting { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
       #mjjbox-custom-setting label { font-size: 16px; display: flex; justify-content: space-between; align-items: center; margin: 12px; }
       #mjjbox-custom-setting label span { color: #6bc; font-size: 12px; font-weight: normal; padding: 0 6px; margin-right: auto; }
       #mjjbox-custom-setting label input { margin: 0 5px 0 15px; }
       #mjjbox-custom-setting label input[type="text"] { width: 350px; padding: 2px; font-size: 14px; }
       #mjjbox-custom-setting label input[type="number"] { width: 70px; padding: 0 0 0 10px; text-align: center; }
       #mjjbox-custom-setting label input[disabled] { background: #CCC; }
       #mjjbox-custom-setting .settings-buttons { display: flex; justify-content: space-around; margin-top: 20px; }
       #mjjbox-custom-setting .settings-buttons button { user-select: none; color: #333; padding: 8px 16px; border-radius: 5px; border: none; line-height: normal; cursor: pointer; }
       #mjjbox-custom-setting hr { display: block; height: 1px; border: 0; border-top: 1px solid var(--primary-low); margin: 1em 0; padding: 0; }
     </style>
     <h2 style="text-align:center; margin-top:.5rem;">MJJBox 自定义设置</h2>
     `;
        Object.keys(settingsConfig).forEach(key => {
            const cfg = settingsConfig[key];
            if (typeof cfg === 'string') {
                html += `<hr><h3 style="margin-top:5px; font-size: 1.1em;">${cfg}</h3>`;
            } else {
                const val = settings[key];
                const checked = cfg.type === 'checkbox' && val ? 'checked' : '';
                html += `<label style="${cfg.style}">${cfg.label}<span>${cfg.info}</span><input type="${cfg.type}" id="ujs_set_${key}" value="${val}" ${checked}></label>`;
            }
        });
        html += `
     <div class="settings-buttons">
         <button id="ld_userjs_apply" style="font-weight: bold; background: var(--tertiary); color: var(--secondary)">保存并刷新</button>
         <button id="ld_userjs_reset" style="background: #fbb;">重置</button>
         <button id="ld_userjs_close" style="background: #ddd;">取消</button>
     </div>`;
        panel.innerHTML = html;

        document.body.append(shadow, panel);

        function saveAndReload() {
            Object.keys(settingsConfig).forEach(key => {
                const element = document.getElementById(`ujs_set_${key}`);
                if (element) {
                    settings[key] = element.type === 'checkbox' ? element.checked : element.value;
                    GM_setValue(key, settings[key]);
                }
            });
            window.location.reload();
        }

        document.querySelector('button#ld_userjs_apply').addEventListener('click', saveAndReload);

        document.querySelector('button#ld_userjs_reset').addEventListener('click', () => {
            Object.keys(settingsConfig).forEach(key => {
                if (typeof settingsConfig[key] === 'object') {
                    GM_deleteValue(key);
                }
            });
            window.location.reload();
        });

        function setting_hide() {
            panel.remove();
            shadow.remove();
        }

        document.querySelector('button#ld_userjs_close').addEventListener('click', setting_hide);
        shadow.addEventListener('click', setting_hide);
    }

    // == 功能区 ==

    // Function 1: 快速收藏
    if (settings.quick_mark) {
        const starSvg = `<svg class="svg-icon" aria-hidden="true" style="text-indent: 1px; transform: scale(1); width:18px; height:18px;">
       <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
       <path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z"></path></svg></svg> `;
        let markMap = new Map();

        function handleResponse(xhr, successCallback, errorCallback) {
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        successCallback(xhr);
                    } else {
                        errorCallback(xhr);
                    }
                }
            };
        }

        function TryParseJson(str) {
            try {
                const jsonObj = JSON.parse(str);
                return JSON.stringify(jsonObj, null, 1);
            } catch (error) {
                return str;
            }
        }

        function deleteStarMark(mark_btn, data_id) {
            if (markMap.has(data_id)) {
                const mark_id = markMap.get(data_id);
                var xhr = new XMLHttpRequest();
                xhr.open('DELETE', `/bookmarks/${mark_id}`, true);
                xhr.setRequestHeader('Content-Type', 'application/json');
                xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
                xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);

                handleResponse(xhr, (xhr) => {
                    mark_btn.style.color = '#777';
                    mark_btn.title = "收藏";
                    mark_btn.onclick = () => addStarMark(mark_btn, data_id);
                }, (xhr) => {
                    console.error('删除收藏失败!', xhr.statusText, TryParseJson(xhr.responseText));
                });

                xhr.send();
            }
        }

        function addStarMark(mark_btn, data_id) {
            const xhr = new XMLHttpRequest();
            xhr.open('POST', '/bookmarks', true);
            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
            xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
            xhr.setRequestHeader('discourse-logged-in', 'true');
            xhr.setRequestHeader('discourse-present', 'true');
            xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);
            const postData = `name=%E6%94%B6%E8%97%8F&auto_delete_preference=3&bookmarkable_id=${data_id}&bookmarkable_type=Post`;

            handleResponse(xhr, (xhr) => {
                mark_btn.style.color = '#fdd459';
                mark_btn.title = "删除收藏";
                const newMark = JSON.parse(xhr.responseText);
                markMap.set(String(newMark.bookmarkable_id), String(newMark.id));
                mark_btn.onclick = () => deleteStarMark(mark_btn, data_id);
            }, (xhr) => {
                console.error('收藏失败!', xhr.statusText, TryParseJson(xhr.responseText));
            });

            xhr.send(postData);
        }

        function addMarkBtn() {
            let articles = document.querySelectorAll("article[data-post-id]");
            if (articles.length <= 0) return;

            articles.forEach(article => {
                const target = article.querySelector("div.topic-body.clearfix > div.regular.contents > section > nav > div.actions");
                if (target && !article.querySelector("span.star-bookmark")) {
                    const dataPostId = article.getAttribute('data-post-id');
                    const starButton = document.createElement('span');

                    starButton.innerHTML = starSvg;
                    starButton.className = "star-bookmark";
                    starButton.style.cursor = 'pointer';
                    starButton.style.margin = '0px 12px';

                    if (markMap.has(dataPostId)) {
                        starButton.style.color = '#fdd459';
                        starButton.title = "删除收藏";
                        starButton.onclick = () => deleteStarMark(starButton, dataPostId);
                    } else {
                        starButton.style.color = '#777';
                        starButton.title = "收藏";
                        starButton.onclick = () => addStarMark(starButton, dataPostId);
                    }
                    target.after(starButton);
                }
            });
        }

        function getStarMark() {
            const currentUserElement = document.querySelector('#current-user button > img[src]');
            if (!currentUserElement) return;

            const srcString = currentUserElement.getAttribute('src');
            const regex = /\/user_avatar\/[^\/]+\/([^\/]+)\/\d+\//;
            const match = srcString.match(regex);
            const currentUsername = match ? match[1] : null;

            if (!currentUsername) return;

            const xhr = new XMLHttpRequest();
            xhr.open('GET', `/u/${currentUsername}/user-menu-bookmarks`, true);
            xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);

            handleResponse(xhr, (xhr) => {
                var response = JSON.parse(xhr.responseText);
                markMap.clear();
                response.bookmarks.forEach(mark => {
                    markMap.set(mark.bookmarkable_id.toString(), mark.id.toString());
                });
                addMarkBtn();
            }, (xhr) => {
                console.error('获取收藏列表失败:', xhr.statusText);
            });

            xhr.send();
        }

        let lastUpdateMarkTime = 0;

        function mutationCallback() {
            const currentTime = Date.now();
            if (currentTime - lastUpdateMarkTime > 5000) { // Reduce frequency of bookmark checks
                getStarMark();
                lastUpdateMarkTime = currentTime;
            } else {
                addMarkBtn(); // Add buttons more frequently for newly loaded posts
            }
        }

        const mainNode = document.querySelector("#main-outlet");
        if (mainNode) {
            const observer = new MutationObserver(mutationCallback);
            observer.observe(mainNode, { childList: true, subtree: true });
            getStarMark(); // Initial run
        }
    }


    // Function 2: 显示话题时间
    if (settings.show_up_time) {
        function getHue(date, currentDate) {
            const diff = Math.abs(currentDate - date);
            const baseday = 30 * 24 * 60 * 60 * 1000; // 30 day
            const diffRatio = Math.min(Math.log(diff / baseday + 1), 1);
            return 120 - (140 * diffRatio); // green to red
        }

        function formatDate(date) {
            const year = date.getFullYear();
            const month = String(date.getMonth() + 1).padStart(2, '0');
            const day = String(date.getDate()).padStart(2, '0');
            const hours = String(date.getHours()).padStart(2, '0');
            const minutes = String(date.getMinutes()).padStart(2, '0');
            return `${year}-${month}-${day} ${hours}:${minutes}`;
        }

        function parseDate(dateStr) {
            let parts;
            // Chinese format: 2024 年 5 月 20 日 14:30
            if (dateStr.match(/(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日\s*(\d+):(\d+)/)) {
                parts = dateStr.match(/(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日\s*(\d+):(\d+)/);
                return new Date(parts[1], parts[2] - 1, parts[3], parts[4], parts[5]);
            }
            // English format: May 20, 2024 2:30 pm
            if (dateStr.match(/(\w+)\s*(\d+),\s*(\d+)\s*(\d+):(\d+)\s*(am|pm)/i)) {
                parts = dateStr.match(/(\w+)\s*(\d+),\s*(\d+)\s*(\d+):(\d+)\s*(am|pm)/i);
                const monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
                let hour = parseInt(parts[4], 10);
                if (parts[6].toLowerCase() === 'pm' && hour < 12) hour += 12;
                else if (parts[6].toLowerCase() === 'am' && hour === 12) hour = 0;
                return new Date(parts[3], monthMap[parts[1]], parts[2], hour, parts[5]);
            }
            return null; // Return null if format is not supported
        }

        GM_addStyle(`
        .topic-list .topic-list-data.age.activity { width: 12em; text-align: left; }
        .topic-list .topic-list-data.age.activity > a.post-activity{
          font-size: 13px; line-height: 1.5; text-wrap: nowrap; padding: 4px 5px; display: block;
        }`);

        function creatTimeShow() {
            document.querySelectorAll(".topic-list-item").forEach(function(row) {
                const item = row.querySelector(".num.topic-list-data.age.activity");
                if (!item || item.dataset.customized) return;
                item.dataset.customized = "true";

                const timeSpan = item.querySelector("a.post-activity");
                if (!timeSpan) return;

                const timeInfo = item.title;
                let createDateString = timeInfo.match(/创建日期:([\s\S]+?)最新:/) || timeInfo.match(/Created: ([\s\S]+?)Latest:/);
                let updateDateString = timeInfo.match(/最新:([\s\S]+)/) || timeInfo.match(/Latest: ([\s\S]+)/);

                if (!createDateString) return;

                createDateString = (createDateString[1] ?? '').trim();
                const createDate = parseDate(createDateString);
                if (!createDate) return;

                const currentDate = new Date();
                const createHue = getHue(createDate, currentDate);
                const formatCreateDate = formatDate(createDate);
                timeSpan.innerHTML = `<span style="color: hsl(${createHue}, 35%, 50%);">创建: ${formatCreateDate}</span><br>`;

                if (updateDateString) {
                    updateDateString = (updateDateString[1] ?? '').trim();
                    const updateDate = parseDate(updateDateString);
                    if (updateDate) {
                        const updateHue = getHue(updateDate, currentDate);
                        const formatNewDate = formatDate(updateDate);
                        timeSpan.innerHTML += `<span style="color: hsl(${updateHue}, 35%, 50%);">最新: ${formatNewDate}</span>`;
                    }
                } else {
                    timeSpan.innerHTML += `<span style="color:#888;">最新: 暂无回复</span>`;
                }

                const pastDays = Math.abs(createDate - currentDate) / (24 * 60 * 60 * 1000);
                const topicTitle = row.querySelector(".main-link");
                if (topicTitle) {
                    if (pastDays > 120) {
                        topicTitle.style.filter = "sepia(90%) brightness(85%)";
                    } else if (pastDays > 60) {
                        topicTitle.style.opacity = 0.8;
                        topicTitle.style.filter = "sepia(40%) brightness(85%)";
                    } else if (pastDays > 30) {
                        topicTitle.style.opacity = 0.9;
                        topicTitle.style.filter = "grayscale(10%) sepia(10%)";
                    }
                }
            });
        }
        setInterval(creatTimeShow, 1000);
    }

    // Function 3: 新窗口打开 (FIXED)
    if (settings.open_in_new) {
        document.addEventListener('click', function(event) {
            const anchor = event.target.closest('a');

            // 1. If it's not a link, or doesn't have an href, or already has a target, ignore it.
            if (!anchor || !anchor.href || anchor.target) {
                return;
            }

            // 2. Ignore middle-clicks or ctrl/cmd-clicks which browsers handle correctly.
            if (event.button !== 0 || event.ctrlKey || event.metaKey) {
                return;
            }

            // 3. Ignore javascript actions.
            if (anchor.href.startsWith('javascript:')) {
                return;
            }

            // 4. Ignore specific UI elements that shouldn't open in a new tab.
            const excludedSelectors = [
                '.d-header-icons', '.nav-pills', '.post-controls',
                '.topic-meta-data .contents', '.topic-timeline',
                '.user-card-actions', '.category-breadcrumb',
                '.select-kit-header', '.select-kit-row', '.modal-body',
                '.actions' // General action buttons
            ].join(', ');

            if (anchor.closest(excludedSelectors)) {
                return;
            }

            // 5. If all checks pass, open the link in a new tab.
            event.preventDefault();
            event.stopImmediatePropagation();
            window.open(anchor.href, '_blank');
        }, true); // Use capture phase to catch the event before the site's own scripts do.
    }


    // Function 4: 显示更精确的回复时间
    if (settings.show_floor_time) {
        GM_addStyle(`
     .post-info.post-date > a > .relative-date {
         font-size: 0; /* Hide original relative date text */
     }
     .post-info.post-date > a > .relative-date::after {
         content: attr(title);
         font-size: 14px; /* Set font size for absolute date */
     }`);
    }

})();