MangaDex: forums button

Helps navigate to MangaDex Forums faster

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MangaDex: forums button
// @namespace    https://andrybak.dev
// @version      5
// @description  Helps navigate to MangaDex Forums faster
// @author       Andrei Rybak
// @license      MIT
// @match        https://mangadex.org/chapter/*
// @icon         https://mangadex.org/pwa/icons/icon-180.png
// @grant        none
// @require      https://cdn.jsdelivr.net/gh/rybak/userscript-libs@dc32d5897dcfa40a01c371c8ee0e211162dfd24c/waitForElement.js
// ==/UserScript==

/*
 * Copyright (c) 2026 Andrei Rybak
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), 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, and to permit persons to whom the Software is
 * furnished to do so, 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. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/* globals waitForElement */

(function() {
	'use strict';

	const LOG_PREFIX = '[MangaDex: forums button]';
	function error(...toLog) {
		console.error(LOG_PREFIX, ...toLog);
	}
	function warn(...toLog) {
		console.warn(LOG_PREFIX, ...toLog);
	}
	function info(...toLog) {
		console.info(LOG_PREFIX, ...toLog);
	}
	function debug(...toLog) {
		console.debug(LOG_PREFIX, ...toLog);
	}

	/*
	 * Selector for the builtin button that opens the new comment section.
	 */
	const THE_BUTTON_SELECTOR = '.md--reader-menu hr + button.accent';
	/*
	 * Selector for the link to the forums in the hidden slide-out with the new comment section.
	 * Desktop, mobile.
	 */
	const FORUMS_LINK_SELECTOR = '.md-content .fixed a[href^="https://forums"], .drawer-mount-area.isolate .fixed a[href^="https://forums"]';
	const FORUMS_BUTTON_ID = 'andrybakForumsButton';
	const FORUMS_BUTTON_SPAN_ID = 'andrybakForumsButtonText';
	const cache = new Map();

	function getChapterId(maybeUrl) {
		const CHAPTER_ID_REGEX = /https:\/\/mangadex\.org\/chapter\/([a-f0-9-]+)(\/[0-9]+)?/g;
		const s = maybeUrl ? maybeUrl : document.location.href;
		const m = CHAPTER_ID_REGEX.exec(s);
		if (!m || !m[1]) {
			info('Not a chapter page:', s);
			info('Got regex result:', m);
			return null;
		}
		return m[1];
	}

	function getStats(chapterId) {
		if (cache[chapterId]) {
			debug('Cache hit for', chapterId);
			return Promise.resolve(cache[chapterId]);
		}
		const apiUrlStats = `https://api.mangadex.org/statistics/chapter/?chapter%5B%5D=${chapterId}`;
		info('Fetching stats API endpoint:', apiUrlStats, ' ...');
		return new Promise((resolve, reject) => {
			fetch(apiUrlStats).then(
				response => {
					if (!response.ok) {
						error('API returned error:', response.status, response.body);
						reject('API error');
						return;
					}
					info('Got something. Parsing JSON...');
					response.json().then(json => {
						const forumThreadId = json?.statistics[chapterId]?.comments?.threadId;
						if (!forumThreadId) {
							error('Cannot find threadId in JSON:', json);
							reject('JSON parsing error');
							return;
						}
						info('New threadId =', forumThreadId);
						const newNumber = json?.statistics[chapterId]?.comments?.repliesCount;
						cache[chapterId] = {
							'threadId': forumThreadId,
							'repliesCount' : newNumber
						};
						resolve(cache[chapterId]);
					}, rejection => {
						error('Cannot get JSON from response.', rejection);
						reject(rejection);
					});
				},
				rejection => {
					error(`Cannot load '${apiUrlStats}'.`, rejection);
					reject(rejection);
				}
			);
		});
	}

	function updateCounter(newNumber) {
		const mySpan = document.getElementById(FORUMS_BUTTON_SPAN_ID);
		mySpan.replaceChildren(document.createTextNode(`Forums: ${newNumber}`));
		info('Updated the button text.');
	}

	function getButton(newNumber) {
		const maybeAlreadyCreated = document.getElementById(FORUMS_BUTTON_ID);
		if (maybeAlreadyCreated != null && maybeAlreadyCreated.parentNode != null) {
			updateCounter(newNumber);
			return maybeAlreadyCreated;
		}
		const newButton = document.createElement('a');
		newButton.id = FORUMS_BUTTON_ID;
		const newSpan = document.createElement('span');
		newSpan.id = FORUMS_BUTTON_SPAN_ID;
		// reproducing styling of `theButton`
		newButton.classList.add('rounded', 'custom-opacity', 'relative', 'md-btn', 'flex', 'items-center', 'px-3', 'overflow-hidden', 'accent', 'px-4');
		newButton.setAttribute('data-v-0082f4a3', '');
		newButton.style.minHeight = '2.5rem';
		newButton.style.minWidth = '100%';
		newSpan.classList.add('flex', 'relative', 'items-center', 'justify-center', 'font-medium', 'select-none', 'w-full');
		newSpan.append(`Forums: ${newNumber}`);
		newButton.target = '_blank'; // to make it open in a new tab
		newButton.append(newSpan);
		waitForElement(`${THE_BUTTON_SELECTOR} > span > svg.icon`).then(ignored => {
			const theButton = document.querySelector(THE_BUTTON_SELECTOR);
			theButton.parentNode.insertBefore(newButton, theButton);
			info('Inserted new button');
		});
		info('Created new button.');
		return newButton;
	}

	let chapterId = getChapterId();
	function chapterChanged() {
		if (!chapterId) {
			error('Cannot find chapterId. Aborting');
			return;
		}
		getStats(chapterId).then(stats => {
			const forumUrl = `https://forums.mangadex.org/threads/${stats.threadId}`;
			const myButton = getButton(stats.repliesCount);
			myButton.href = forumUrl;
		}, rejection => {
			error(`Cannot load stats for chapterId=${chapterId}. Got error:`, rejection);
		});
	}
	chapterChanged(); // fire the "listener" immediately

	function checkChapterId(logMessage, newUrl) {
		const maybeNewChapterId = getChapterId(newUrl);
		info(`${logMessage} triggered. maybeNewChapterId = ${maybeNewChapterId}`);
		if (chapterId != maybeNewChapterId) {
			chapterId = maybeNewChapterId;
			info('CHAPTER ID CHANGED:', chapterId);
			chapterChanged();
		}
	}

	function observe(selector) {
		const observer = new MutationObserver(mutations => {
			checkChapterId(`Observer for "${selector}"`);
		});
		waitForElement(selector).then(e => {
			observer.observe(e, {
				childList: true,
				subtree: true
			});
			info('Connected the observer to', e);
		});
	}
	observe(THE_BUTTON_SELECTOR);
	window.navigation.addEventListener('navigate', (event) => {
		// info(event);
		checkChapterId('window.navigation "navigate" event', event.destination.url);
	});
})();