// ==UserScript==
// @name OPFPHider
// @name:zh-CN OPFP隐藏器
// @namespace URL
// @version 2.3.5
// @description Hide Osu! Profile sections optionally
// @description:zh-CN 可选地隐藏Osu!个人资料的各个不同部分
// @author Sisyphus
// @license MIT
// @homepage https://github.com/SisypheOvO
// @match https://osu.ppy.sh/users/*
// @run-at document-end
// @grant none
// @downloadURL https://raw.githubusercontent.com/SisypheOvO/OPFPHider/main/dist/opfphider.user.js
// @updateURL https://raw.githubusercontent.com/SisypheOvO/OPFPHider/main/dist/opfphider.user.js
// ==/UserScript==
(function () {
'use strict';
const TARGET_PAGE_IDS = ["me", "beatmaps", "recent_activity", "top_ranks", "medals", "historical", "kudosu"];
const CHEVRON_ICONS = {
DOWN: '',
UP: '',
};
const STORAGE_KEYS = {
COLLAPSED: "opfphider-collapsed-states",
REMOVED: "opfphider-remove-states",
LANGUAGE: "opfphider-language",
};
const I18N = {
en: {
collapseDescription: "Pages collapsed by default",
removeDescription: "Pages hidden completely",
save: "Save",
cancel: "Cancel",
refreshNotification: "Settings saved! Changes to removed pages require a page refresh to take effect.",
},
"zh-CN": {
collapseDescription: "默认收起的模块",
removeDescription: "直接隐藏的模块",
save: "保存",
cancel: "取消",
refreshNotification: "设置已保存!删除页面的更改需要刷新页面才能生效。",
},
ja: {
collapseDescription: "デフォルトで折りたたむモジュール",
removeDescription: "完全に非表示にするモジュール",
save: "保存",
cancel: "キャンセル",
refreshNotification: "設定を保存しました!削除したページの変更を反映するにはページを更新してください。",
},
ko: {
collapseDescription: "기본적으로 접힌 모듈",
removeDescription: "완전히 숨기는 모듈",
save: "저장",
cancel: "취소",
refreshNotification: "설정이 저장되었습니다! 삭제된 페이지 변경사항을 적용하려면 페이지를 새로고침해야 합니다.",
},
ru: {
// cSpell:disable
collapseDescription: "Модули, свёрнутые по умолчанию",
removeDescription: "Модули, полностью скрытые",
save: "Сохранить",
cancel: "Отмена",
refreshNotification: "Настройки сохранены! Для применения изменений к удалённым страницам требуется перезагрузка страницы.",
}, // cSpell:enable
};
class StorageManager {
static get(key, fallback) {
try {
const value = localStorage.getItem(key);
if (!value)
return fallback;
return typeof fallback === "object" ? JSON.parse(value) : value;
}
catch {
return fallback;
}
}
static set(key, value) {
try {
const data = typeof value === "object" ? JSON.stringify(value) : value;
localStorage.setItem(key, data);
}
catch (e) {
console.error("[Storage] Failed to save:", e);
}
}
}
StorageManager.collapsed = {
get: () => StorageManager.get(STORAGE_KEYS.COLLAPSED, {}),
set: (states) => StorageManager.set(STORAGE_KEYS.COLLAPSED, states),
};
StorageManager.removed = {
get: () => StorageManager.get(STORAGE_KEYS.REMOVED, {}),
set: (states) => StorageManager.set(STORAGE_KEYS.REMOVED, states),
};
StorageManager.language = {
get: () => StorageManager.get(STORAGE_KEYS.LANGUAGE, "en"),
set: (lang) => StorageManager.set(STORAGE_KEYS.LANGUAGE, lang),
};
// src/utils/dom.ts
class DomUtils {
static getPageName(pageId) {
const pageContainer = document.querySelector(`.js-sortable--page[data-page-id="${pageId}"]`);
if (!pageContainer)
return pageId;
const titleElement = pageContainer.querySelector(".u-relative .title.title--page-extra h2");
if (titleElement) {
return titleElement.textContent?.trim() || pageId;
}
const fallbackTitle = pageContainer.querySelector(".u-relative h2");
if (fallbackTitle) {
return fallbackTitle.textContent?.trim() || pageId;
}
return pageId;
}
static getAllPageNames() {
const pageNames = {};
TARGET_PAGE_IDS.forEach((pageId) => {
pageNames[pageId] = this.getPageName(pageId);
});
return pageNames;
}
static createCollapseButton() {
const button = document.createElement("button");
button.className = "custom-inserted-button";
button.innerHTML = `
`;
return button;
}
static updateButtonIcon(button, isCollapsed) {
const chevronIcon = button.querySelector(".chevron-icon");
if (chevronIcon) {
chevronIcon.innerHTML = isCollapsed ? CHEVRON_ICONS.DOWN : CHEVRON_ICONS.UP;
}
}
static calculateHeaderHeight(pageId, uRelative) {
const uRelativeHeight = uRelative.offsetHeight;
const computedStyle = window.getComputedStyle(uRelative);
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
const extraBuffer = 14;
let totalHeaderHeight = uRelativeHeight + paddingTop + paddingBottom + extraBuffer;
if (pageId === "me") {
const meExpander = document.querySelector('.js-sortable--page[data-page-id="me"] .me-expander');
if (meExpander) {
const meExpanderHeight = meExpander.offsetHeight;
const meExpanderComputedStyle = window.getComputedStyle(meExpander);
const meExpanderMarginTop = parseFloat(meExpanderComputedStyle.marginTop) || 0;
const meExpanderMarginBottom = parseFloat(meExpanderComputedStyle.marginBottom) || 0;
totalHeaderHeight += meExpanderHeight + meExpanderMarginTop + meExpanderMarginBottom;
}
}
return totalHeaderHeight;
}
static removePageElement(pageId) {
const pageContainer = document.querySelector(`.js-sortable--page[data-page-id="${pageId}"]`);
if (pageContainer) {
pageContainer.style.display = "none";
}
// 删除标签页导航
const tabLink = document.querySelector(`.page-mode--profile-page-extra a.page-mode__item.js-sortable--tab.ui-sortable-handle[data-page-id="${pageId}"]`);
if (tabLink) {
tabLink.style.display = "none";
}
}
static injectStyles() {
if (document.querySelector("#opfphider-styles"))
return;
const style = document.createElement("style");
style.id = "opfphider-styles";
style.textContent = this.getStyles();
document.head.appendChild(style);
}
static getStyles() {
return `
/* 设置按钮 */
#opfphider-settings-btn {
position: fixed;
bottom: 16px;
right: 16px;
width: 36px;
height: 36px;
background: hsl(var(--hsl-h2));
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
transition: all 0.2s ease;
}
#opfphider-settings-btn:hover {
background: hsl(var(--hsl-h1));
filter: brightness(0.95);
transform: scale(1.05) rotate(90deg);
}
/* 折叠按钮 */
.custom-inserted-button {
width: 30px;
height: 30px;
padding: 0;
background: hsl(var(--hsl-h2));
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 10;
position: absolute;
right: 14px;
top: 14px;
}
.custom-inserted-button:hover {
background: hsl(var(--hsl-h1));
}
/* 设置面板 */
#opfphider-settings-panel {
position: fixed;
bottom: 70px;
right: 16px;
width: 280px;
background: #2e3038;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
padding: 20px;
z-index: 10000;
font-family: system-ui, -apple-system, sans-serif;
max-height: 80vh;
overflow-y: auto;
}
/* 设置面板滚动条样式 */
#opfphider-settings-panel::-webkit-scrollbar {
width: 6px;
}
#opfphider-settings-panel::-webkit-scrollbar-thumb {
background: hsl(var(--hsl-h2));
border-radius: 4px;
}
#opfphider-settings-panel::-webkit-scrollbar-thumb:hover {
background: hsl(var(--hsl-h1));
}
/* 其他现有样式保持不变 */
.page-extra {
position: relative !important;
}
.sortable-handle--profile-page-extra {
margin-right: 14px !important;
margin-top: -3px !important;
}
.page-extra--userpage .sortable-handle--profile-page-extra {
margin-right: 54px !important;
margin-top: -3px !important;
}
.page-extra__actions {
right: 74px !important;
top: 14px !important;
}
.chevron-icon {
transition: transform 0.3s ease;
}
#opfphider-save, #opfphider-cancel {
width: 80px;
padding: 8px;
color: white;
border: none;
border-radius: 9999px;
cursor: pointer;
font-size: 12px;
flex: 1;
}
#opfphider-save {
background: hsl(var(--hsl-h2));
}
#opfphider-cancel {
background: hsl(var(--hsl-b2));
}
#opfphider-save:hover {
background: hsl(var(--hsl-h1));
transition: background-color .2s;
text-transform: none;
}
#opfphider-cancel:hover {
background: hsl(var(--hsl-b1));
transition: background-color .2s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`;
}
}
class DomWaiter {
/**
* 等待元素出现,针对 Turbo 优化
*/
static waitForElement(selector, timeout = 5000) {
return new Promise((resolve) => {
// 先立即检查
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
let timer = null;
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
if (timer !== null)
clearTimeout(timer);
resolve(element);
}
});
// 只监听 body (Turbo 渲染主要在 body 内部进行)
observer.observe(document.body, {
childList: true,
subtree: true,
});
timer = window.setTimeout(() => {
observer.disconnect();
console.warn(`[DomWaiter] Timeout waiting for: ${selector}`);
resolve(null);
}, timeout);
});
}
static waitForPageElement(pageId, timeout = 5000) {
const selector = `.js-sortable--page[data-page-id="${pageId}"]`;
return this.waitForElement(selector, timeout);
}
}
class PageHandler {
constructor() {
this.pageStates = new Map();
this.isInitializing = true;
this.initTimer = null;
this.initTimer = window.setTimeout(() => {
this.isInitializing = false;
this.initTimer = null;
}, 1000);
}
async processRemoveStates() {
const removeStates = StorageManager.removed.get();
for (const pageId of TARGET_PAGE_IDS) {
if (removeStates[pageId]) {
await DomWaiter.waitForPageElement(pageId, 3000);
DomUtils.removePageElement(pageId);
}
}
}
async insertButtonForPage(pageId) {
const removeStates = StorageManager.removed.get();
if (removeStates[pageId]) {
return;
}
const selector = `.js-sortable--page[data-page-id="${pageId}"] .page-extra`;
const targetElement = await DomWaiter.waitForElement(selector, 3000);
if (!targetElement) {
console.warn(`[OPFP Hider] Element not found for page: ${pageId}`);
return;
}
if (targetElement.querySelector(".custom-inserted-button")) {
console.log(`[OPFP Hider] Button already exists for: ${pageId}`);
return;
}
const button = DomUtils.createCollapseButton();
button.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
this.handleButtonClick(pageId, button);
});
targetElement.appendChild(button);
await this.initializePageState(pageId, button);
}
async initializePageState(pageId, button) {
const storedStates = StorageManager.collapsed.get();
const isCollapsed = storedStates.hasOwnProperty(pageId) ? storedStates[pageId] : false;
this.pageStates.set(pageId, isCollapsed);
// 等待下一帧确保 DOM 更新
await new Promise((resolve) => requestAnimationFrame(resolve));
DomUtils.updateButtonIcon(button, isCollapsed);
if (isCollapsed) {
this.collapsePage(pageId, true);
}
}
handleButtonClick(pageId, button) {
if (this.isInitializing) {
console.log("[OPFP Hider] Still initializing, skip button click");
return;
}
const isCurrentlyCollapsed = this.pageStates.get(pageId) || false;
const newState = !isCurrentlyCollapsed;
this.pageStates.set(pageId, newState);
DomUtils.updateButtonIcon(button, newState);
newState ? this.collapsePage(pageId) : this.expandPage(pageId);
}
collapsePage(pageId, immediate = false) {
const pageContainer = document.querySelector(`.js-sortable--page[data-page-id="${pageId}"]`);
if (!pageContainer)
return;
const pageExtra = pageContainer.querySelector(".page-extra");
if (!pageExtra)
return;
const uRelative = pageContainer.querySelector(".u-relative");
if (!uRelative)
return;
const totalHeaderHeight = DomUtils.calculateHeaderHeight(pageId, uRelative);
if (!pageExtra.dataset.originalHeight) {
pageExtra.dataset.originalHeight = pageExtra.offsetHeight + "px";
}
if (immediate) {
pageExtra.style.height = totalHeaderHeight + "px";
pageExtra.style.overflow = "hidden";
pageExtra.style.transition = "none";
return;
}
pageExtra.style.overflow = "hidden";
const currentHeight = pageExtra.offsetHeight;
pageExtra.style.height = currentHeight + "px";
pageExtra.style.transition = "height 0.3s ease";
pageExtra.offsetHeight; // Force reflow
setTimeout(() => {
pageExtra.style.height = totalHeaderHeight + "px";
}, 10);
}
expandPage(pageId) {
const pageContainer = document.querySelector(`.js-sortable--page[data-page-id="${pageId}"]`);
if (!pageContainer)
return;
const pageExtra = pageContainer.querySelector(".page-extra");
if (!pageExtra)
return;
const targetHeight = pageExtra.dataset.originalHeight ? parseInt(pageExtra.dataset.originalHeight) : pageExtra.scrollHeight;
pageExtra.style.transition = "height 0.3s ease";
pageExtra.style.height = targetHeight + "px";
setTimeout(() => {
pageExtra.style.height = "";
pageExtra.style.overflow = "";
pageExtra.style.transition = "";
}, 300);
}
}
class SettingsPanel {
constructor(i18n) {
this.i18n = i18n;
}
toggle() {
const existingPanel = document.querySelector("#opfphider-settings-panel");
if (existingPanel) {
existingPanel.remove();
return;
}
this.createPanel();
}
ensureEventListeners() {
const existingPanel = document.querySelector("#opfphider-settings-panel");
if (existingPanel) {
this.reattachEventListeners(existingPanel);
}
}
reattachEventListeners(panel) {
const languageSelect = panel.querySelector("#opfphider-language-select");
const saveButton = panel.querySelector("#opfphider-save");
const cancelButton = panel.querySelector("#opfphider-cancel");
if (this.languageChangeHandler) {
languageSelect?.removeEventListener("change", this.languageChangeHandler);
}
if (this.saveHandler) {
saveButton?.removeEventListener("click", this.saveHandler);
}
if (this.cancelHandler) {
cancelButton?.removeEventListener("click", this.cancelHandler);
}
this.attachEventListeners(panel);
}
createPanel() {
const storedStates = StorageManager.collapsed.get();
const removeStates = StorageManager.removed.get();
const pageNames = DomUtils.getAllPageNames();
const currentLang = this.i18n.getCurrentLanguage();
const panel = document.createElement("div");
panel.id = "opfphider-settings-panel";
panel.innerHTML = `
OPFP Hider Settings
${this.i18n.getTranslation("collapseDescription")}
${TARGET_PAGE_IDS.map((pageId) => `
`).join("")}
${this.i18n.getTranslation("removeDescription")}
${TARGET_PAGE_IDS.map((pageId) => `
`).join("")}
`;
document.body.appendChild(panel);
this.attachEventListeners(panel);
}
getLanguageName(lang) {
const names = {
en: "English",
"zh-CN": "中文",
ja: "日本語",
ko: "한국어",
ru: "Русский",
};
return names[lang] || lang;
}
attachEventListeners(panel) {
const languageSelect = panel.querySelector("#opfphider-language-select");
const saveButton = panel.querySelector("#opfphider-save");
const cancelButton = panel.querySelector("#opfphider-cancel");
// 创建命名函数并保存引用
this.languageChangeHandler = (e) => {
const target = e.target;
this.i18n.setLanguage(target.value);
panel.remove();
this.toggle();
};
this.saveHandler = () => this.saveSettings(panel);
this.cancelHandler = () => panel.remove();
languageSelect?.addEventListener("change", this.languageChangeHandler);
saveButton?.addEventListener("click", this.saveHandler);
cancelButton?.addEventListener("click", this.cancelHandler);
}
saveSettings(panel) {
const collapseCheckboxes = panel.querySelectorAll('input[data-type="collapse"]');
const removeCheckboxes = panel.querySelectorAll('input[data-type="remove"]');
const newCollapseStates = {};
collapseCheckboxes.forEach((checkbox) => {
const input = checkbox;
newCollapseStates[input.dataset.page] = input.checked;
});
const newRemoveStates = {};
removeCheckboxes.forEach((checkbox) => {
const input = checkbox;
newRemoveStates[input.dataset.page] = input.checked;
});
StorageManager.collapsed.set(newCollapseStates);
StorageManager.removed.set(newRemoveStates);
panel.remove();
}
}
class I18nManager {
constructor() {
this.currentLanguage = this.detectLanguage();
}
detectLanguage() {
const storedLang = StorageManager.language.get();
if (storedLang && I18N[storedLang]) {
return storedLang;
}
const htmlLang = document.documentElement.lang;
if (htmlLang) {
const primaryLang = htmlLang.split("-")[0];
const fullLang = htmlLang;
if (I18N[fullLang])
return fullLang;
if (I18N[primaryLang])
return primaryLang;
}
const browserLang = navigator.language || navigator.userLanguage;
if (browserLang) {
const primaryBrowserLang = browserLang.split("-")[0];
const fullBrowserLang = browserLang;
if (I18N[fullBrowserLang])
return fullBrowserLang;
if (I18N[primaryBrowserLang])
return primaryBrowserLang;
}
return "en";
}
getTranslation(key) {
const strings = I18N[this.currentLanguage] || I18N.en;
return strings[key] || key;
}
setLanguage(lang) {
if (I18N[lang]) {
this.currentLanguage = lang;
StorageManager.language.set(lang);
}
}
getCurrentLanguage() {
return this.currentLanguage;
}
}
class OPFPHiderManager {
constructor() {
this.isProcessing = false;
this.i18n = new I18nManager();
this.pageHandler = new PageHandler();
this.settingsPanel = new SettingsPanel(this.i18n);
this.settingsButtonHandler = () => this.settingsPanel.toggle();
}
init() {
DomUtils.injectStyles();
this.setupTurboListeners();
this.handlePageUpdate();
}
setupTurboListeners() {
// Turbo 渲染完成后触发
document.addEventListener("turbo:render", () => {
console.log("[OPFP Hider] Turbo render event");
this.settingsPanel.ensureEventListeners();
this.handlePageUpdate();
});
// Turbo 加载完成后触发(包含异步内容)
document.addEventListener("turbo:load", () => {
console.log("[OPFP Hider] Turbo load event");
this.settingsPanel.ensureEventListeners();
this.handlePageUpdate();
});
// 备用:监听 turbo:frame-render(如果使用了 Turbo Frames)
document.addEventListener("turbo:frame-render", () => {
console.log("[OPFP Hider] Turbo frame render event");
this.settingsPanel.ensureEventListeners();
this.handlePageUpdate();
});
}
addSettingsButton() {
let settingsBtn = document.querySelector("#opfphider-settings-btn");
if (settingsBtn) {
settingsBtn.removeEventListener("click", this.settingsButtonHandler);
settingsBtn.addEventListener("click", this.settingsButtonHandler);
}
else {
settingsBtn = document.createElement("button");
settingsBtn.id = "opfphider-settings-btn";
settingsBtn.innerHTML = "⚙️";
settingsBtn.addEventListener("click", this.settingsButtonHandler);
document.body.appendChild(settingsBtn);
}
}
async handlePageUpdate() {
// 防止重复处理
if (this.isProcessing) {
console.log("[OPFP Hider] Already processing, skip");
return;
}
// 只在用户个人页面执行
if (!location.pathname.startsWith("/users/")) {
console.log("[OPFP Hider] Not on user profile page");
return;
}
this.isProcessing = true;
console.log("[OPFP Hider] Start processing page");
try {
this.addSettingsButton();
await this.pageHandler.processRemoveStates();
// 并发插入
await Promise.all(TARGET_PAGE_IDS.map((pageId) => this.pageHandler.insertButtonForPage(pageId)));
console.log("[OPFP Hider] Page processing complete");
}
catch (error) {
console.error("[OPFP Hider] Error updating page:", error);
}
finally {
this.isProcessing = false;
}
}
}
function init() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
new OPFPHiderManager().init();
});
}
else {
new OPFPHiderManager().init();
}
}
init();
})();