// ==UserScript==
// @name MJJBOX Enhancement Suite
// @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 */
}`);
}
})();