Curseforge QOL Fixes

Various Quality of Life improvements to the Curseforge website

当前为 2023-04-22 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Curseforge QOL Fixes
  3. // @version 0.25
  4. // @description Various Quality of Life improvements to the Curseforge website
  5. // @author comp500
  6. // @namespace https://infra.link/
  7. // @match https://www.curseforge.com/*
  8. // @match https://legacy.curseforge.com/*
  9. // @homepageURL https://github.com/comp500/Curseforge-Userscripts/
  10. // @supportURL https://github.com/comp500/Curseforge-Userscripts/issues/
  11. // @source https://github.com/comp500/Curseforge-Userscripts/
  12. // @run-at document-end
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. "use strict";
  18.  
  19. // Add a search box
  20. let searchBoxContainer = document.createElement("div");
  21. searchBoxContainer.className = "flex mr-4 items-center";
  22. // Get the current assets path
  23. let styleSheet = Array.from(document.styleSheets).find((s) => /\/Content\/([\d\-]+)\//.test(s.href));
  24. let assetsPath = styleSheet == null ? "2-0-7179-35052" : /\/Content\/([\d\-]+)\//.exec(styleSheet.href)[1];
  25. searchBoxContainer.innerHTML = `<form action="/minecraft/mc-mods/search" method="get" novalidate="novalidate" autocomplete="false" style="width:100%">
  26. <div class="flex flex-col h-full justify-between">
  27. <div class="input input--icon" style="color: #000">
  28. <i class="search textgray-900 flex items-center justify-center">
  29. <svg class="icon" viewBox="0 0 20 20" width="16" height="16"><use xlink:href="/Content/${assetsPath}/Skins/CurseForge/images/twitch/Object/Search.svg#Object/Search"></use></svg>
  30. </i>
  31. <input type="text" name="search" id="cfqolTopbarSearch" placeholder="Search Mods">
  32. </div>
  33. </div></form>`;
  34. let insertLocation = document.querySelector(".curseforge-header .ml-auto > div");
  35. if (insertLocation != null && insertLocation.firstChild != null) {
  36. // @Inject(method = "the navbar", at = @At("HEAD"))
  37. insertLocation.insertBefore(searchBoxContainer, insertLocation.firstChild);
  38.  
  39. // Make the search box magically grow
  40. let searchBox = searchBoxContainer.querySelector("#cfqolTopbarSearch");
  41. if (searchBox != null) {
  42. // Fix stupid flexboxes - set to flex-grow 1 flex-shrink 0
  43. searchBoxContainer.style.flex = "1 0";
  44. searchBoxContainer.parentNode.parentNode.style.flex = "1 0";
  45.  
  46. let navLinksContainer = document.querySelector(".top-nav__nav-link").parentNode;
  47. navLinksContainer.style.transition = "opacity 0.4s, max-width 0.3s";
  48. searchBox.addEventListener("focus", (e) => {
  49. navLinksContainer.style.opacity = 0;
  50. navLinksContainer.style.maxWidth = "0";
  51. });
  52. searchBox.addEventListener("blur", (e) => {
  53. navLinksContainer.style.opacity = 1;
  54. navLinksContainer.style.maxWidth = "2000px";
  55. });
  56. // Make the search icon focus the search box
  57. let searchIcon = searchBoxContainer.querySelector(".search");
  58. if (searchIcon != null) {
  59. searchIcon.addEventListener("click", (e) => {
  60. searchBox.focus();
  61. });
  62. }
  63. }
  64. }
  65.  
  66. // Add an "All Files" tab for all curseforge projects
  67. let projectPathMatches = /^\/([\w-]+)\/([\w-]+)\/([a-z][\da-z-_]{0,127})/.exec(document.location.pathname);
  68. let filesTab = document.getElementById("nav-files");
  69. if (projectPathMatches != null && projectPathMatches.length == 4 && filesTab != null) {
  70. let gameName = projectPathMatches[1];
  71. let projectCategory = projectPathMatches[2];
  72. let projectSlug = projectPathMatches[3];
  73.  
  74. let projectAllFiles = document.createElement("li");
  75. let isAllFilesPage = /\/files\/all$/.test(document.location.pathname);
  76. if (isAllFilesPage) {
  77. projectAllFiles.className =
  78. "border-b-2 border-primary-500 b-list-item p-nav-item px-2 pb-1/10 -mb-1/10 text-gray-500";
  79. filesTab.className = "b-list-item p-nav-item px-2 pb-1/10 -mb-1/10 text-gray-500";
  80. filesTab.getElementsByTagName("a")[0].className = "text-gray-500 hover:no-underline";
  81. } else {
  82. projectAllFiles.className = "b-list-item p-nav-item px-2 pb-1/10 -mb-1/10 text-gray-500";
  83. }
  84. projectAllFiles.innerHTML = `<a href="/${gameName}/${projectCategory}/${projectSlug}/files/all" class="text-${
  85. isAllFilesPage ? "primary" : "gray"
  86. }-500 hover:no-underline">
  87. <span class="b-list-label">
  88. All Files
  89. </span>
  90. </a>`;
  91. filesTab.parentNode.insertBefore(projectAllFiles, filesTab.nextSibling);
  92. }
  93.  
  94. // Add pagination to the bottom of the page in dependency lists
  95. let dependenciesPage = document.querySelector(".project-dependencies-page > div");
  96. if (dependenciesPage != null) {
  97. let paginationTop = document.querySelector(".project-dependencies-page > div .pagination-top");
  98. if (paginationTop != null) {
  99. dependenciesPage.appendChild(paginationTop.parentNode.cloneNode(true)).classList.remove("mb-4");
  100. }
  101. }
  102.  
  103. // Skip download countdowns
  104. let downloadScript = Array.from(document.scripts).find(
  105. (s) => s.innerText != null && s.innerText.includes("PublicProjectDownload.countdown")
  106. );
  107. if (downloadScript != null && downloadScript.innerText != null) {
  108. let matches = downloadScript.innerText.match(/countdown\("(.+)"\)/);
  109. if (matches != null && matches[1] != null) {
  110. // Break the existing script
  111. let countdownEl = document.querySelector("span[data-countdown-seconds]");
  112. if (countdownEl != null) {
  113. // UNSAFE if grant != none! For some reason jQuery stores data in itself rather than attrs?!
  114. jQuery.removeData(countdownEl, "countdown-seconds");
  115. }
  116.  
  117. let downloadText = document.querySelector("p[data-countdown-timer]");
  118. if (downloadText != null) {
  119. downloadText.innerText = "Downloading now...";
  120. }
  121.  
  122. window.location.href = matches[1];
  123. }
  124. }
  125. /**
  126. * Link redirections
  127. */
  128.  
  129. const linkList = Array.from(document.getElementsByTagName("a"));
  130. const regexDownloadLink = /^https:\/\/(www|legacy).curseforge.com\/.*\/download\/\d+$/;
  131.  
  132. const redirections = [
  133. // Better method for skipping, if links contain file ID already
  134. [regexDownloadLink, a => {
  135. a.href = a.href + "/file";
  136. }],
  137. // Change the default Minecraft tab (from other links) to /minecraft/mc-mods
  138. [/^http:\/\/bit.ly\/2Lzpfsl|https:\/\/(www|legacy).curseforge.com\/minecraft\/?$/, a => {
  139. a.path = "/minecraft/mc-mods";
  140. }],
  141. // Change the default member tab to projects
  142. [/^https:\/\/(www|legacy).curseforge.com\/members\/[^\/]+\/?$/, a => {
  143. a.href = a.href + (a.href.endsWith("/") ? "" : "/") + "projects";
  144. }],
  145. // Redirect linkout URLs
  146. [/^https:\/\/(www|legacy).curseforge.com\/linkout/, a => {
  147. let url = new URL(a.href);
  148. a.href = decodeURIComponent(url.searchParams.get("remoteUrl"));
  149. }],
  150. // Change the default dependency type to "Required Dependency"
  151. [/^https:\/\/(www|legacy).curseforge.com\/([\w-]+)\/([\w-]+)\/([a-z][\da-z-_]{0,127})\/relations\/dependencies\/?$/, a => {
  152. a.href = a.href + "?filter-related-dependencies=3";
  153. }]
  154. ];
  155. for (let link of linkList) {
  156. for (let redir of redirections) {
  157. if (redir[0].test(link.href)) {
  158. redir[1](link);
  159. break;
  160. }
  161. }
  162. }
  163.  
  164. /**
  165. * Readd download buttons for modpacks
  166. */
  167.  
  168. // All Files list
  169. Array.from(document.querySelectorAll("table.listing a.button"))
  170. .filter(l => l.pathname.startsWith("/minecraft/modpacks") && l.href.endsWith("?client=y"))
  171. .map(link => {
  172. let newHref = link.href.slice(0, -9);
  173. if (regexDownloadLink.test(newHref)) {
  174. newHref = newHref + "/file";
  175. }
  176.  
  177. let newLink = link.cloneNode(true);
  178. newLink.href = newHref;
  179. newLink.classList.add("button--icon-only");
  180. newLink.classList.add("mr-2");
  181.  
  182. newLink.innerHTML = `<span class="button__text">
  183. <svg class="icon icon-fixed-width icon-margin" viewBox="0 0 20 20" width="18" height="18">
  184. <use xlink:href="/Content/${assetsPath}/Skins/CurseForge/images/twitch/Action/Download.svg#Action/Download"></use>
  185. </svg>
  186. </span>`;
  187.  
  188. if (link.parentNode != null) {
  189. link.parentNode.insertBefore(newLink, link);
  190. }
  191. });
  192. // Main File button, Page header button
  193. Array.from(document.querySelectorAll("article a.button, header a.button"))
  194. .filter((l) => l.pathname.startsWith("/minecraft/modpacks") && l.href.endsWith("?client=y"))
  195. .map((link) => {
  196. let newHref = link.href.slice(0, -9);
  197. if (regexDownloadLink.test(newHref)) {
  198. newHref = newHref + "/file";
  199. }
  200.  
  201. if (link.parentNode.parentNode.childElementCount >= 2) {
  202. // For some reason, direct file pages now have it, but not the main files page?
  203. return;
  204. }
  205.  
  206. let newButton = link.parentNode.cloneNode(true);
  207. newButton.classList.remove("ml-2");
  208. let newLink = newButton.querySelector("a.button");
  209. newLink.classList.add("button--hollow");
  210. newLink.href = newHref;
  211.  
  212. newLink.innerHTML = `<span class="button__text">
  213. <svg class="icon icon-margin" viewBox="0 0 20 20" width="18" height="18">
  214. <use xlink:href="/Content/${assetsPath}/Skins/CurseForge/images/twitch/Action/Download.svg#Action/Download"></use>
  215. </svg> Download
  216. </span>`;
  217. link.parentNode.parentNode.insertBefore(newButton, link.parentNode);
  218. });
  219.  
  220. // Minecraft version-specific files list
  221. Array.from(document.querySelectorAll(".cf-recentfiles-credits-wrapper"))
  222. .filter((w) => w.firstChild == null || (w.childNodes.length == 1 && w.firstChild.nodeType != Node.ELEMENT_NODE))
  223. .forEach((wrapper) => {
  224. let link = wrapper.parentNode.querySelector("a");
  225.  
  226. if (link != null) {
  227. let newHref = link.href.replace("files", "download");
  228. if (regexDownloadLink.test(newHref)) {
  229. newHref = newHref + "/file";
  230. }
  231. wrapper.innerHTML = `<a href="${newHref}" class="button button--icon-only button--sidebar">
  232. <span class="button__text">
  233. <svg class="icon icon-fixed-width icon-margin" viewBox="0 0 20 20" width="16" height="16"><use xlink:href="/Content/${assetsPath}/Skins/CurseForge/images/twitch/Action/Download.svg#Action/Download"></use></svg>
  234. </span>
  235. </a>`;
  236. }
  237. });
  238. // Sort file dependency lists
  239. let sortNodeList = (list, sortBy) => {
  240. let pairs = Array.from(list).map(el => [el, el.parentNode]);
  241. pairs.sort((a, b) => sortBy(a[0], b[0]));
  242. pairs.forEach(pair => pair[1].removeChild(pair[0]));
  243. pairs.forEach(pair => pair[1].appendChild(pair[0]));
  244. };
  245.  
  246. let relatedProjectsHeading = Array.from(document.querySelectorAll("section > h3")).find(heading => heading.innerText == "Related Projects");
  247. if (relatedProjectsHeading != undefined) {
  248. sortNodeList(relatedProjectsHeading.parentNode.querySelectorAll("section > div.flex > div"), (a, b) =>
  249. a.querySelector("p.font-bold > a").innerText.localeCompare(b.querySelector("p.font-bold > a").innerText));
  250. }
  251. })();