├── README.md ├── LICENSE ├── CONTRIBUTING.md └── aops-enhanced.user.js /README.md: -------------------------------------------------------------------------------- 1 | # AoPS Enhanced 2 | AoPS Enhanced is a userscript that adds and improves various features of the AoPS website. 3 | This userscript is not created by, endorsed by, or otherwise affiliated with Art of Problem Solving Incorporated. 4 | 5 | ## Installation 6 | 7 | 1. Install [Violentmonkey](https://violentmonkey.github.io/get-it/) 8 | 2. [Click here](aops-enhanced.user.js?raw=1) 9 | 10 | ## Settings 11 | Once installed you can configure settings through the dropdown menu in the top right corner. 12 | 13 | 14 | ## Contributing 15 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) if you would like to contribute. 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 epiccakeking 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | If you would like to contribute, here's some information about the project: 3 | 4 | ## Guidelines on suggesting new features 5 | If you would like to suggest a feature, feel free to do so! Don't be afraid to make suggestions. 6 | Even if you lack the technical skills to implement, you can use the GitHub issue tracker to make a request for a new feature. 7 | If you do create an issue, make sure to be descriptive. 8 | The more information you give the better others can understand what you are asking for. 9 | 10 | ## Filing bug reports 11 | If you encounter a bug, please open an issue on GitHub. 12 | 13 | ## Guidelines on implementing features 14 | If you would like to implement a new feature, here are some things to keep in mind: 15 | 16 | ### Use the latest branch 17 | Devel branches are no longer used, instead you should use the latest major version branch for development, unless you are intentionally backporting/bugfixing an older branch. 18 | 19 | ### Use hooks 20 | AoPS Enhanced has a feature called hooks that makes it easier to create features that instantly toggle on and off. 21 | Your feature should generally be of the form of a block that at the end calls add_hook. 22 | 23 | For example, for the general layout of a toggleable feature: 24 | ```javascript 25 | // Some comment to describe your feature 26 | { 27 | // Initialize variables such as elements that will be reused. 28 | // YOUR CODE HERE 29 | enhanced_settings.add_hook('your_feature', value => { 30 | if (value) { 31 | // Handle enabling the feature 32 | // YOUR CODE HERE 33 | } else { 34 | // Handle disabling the feature 35 | // YOUR CODE HERE 36 | } 37 | }); 38 | } 39 | ``` 40 | 41 | ### Clean failure 42 | Your feature should first of all avoid potential errors as much as possible. 43 | In case that is not possible, make sure it does not cause other features to fail. 44 | In particular one of the things to be careful about is when AoPS.Community is not set. 45 | 46 | ### Implementing old features 47 | Here is a list of some features dropped/not yet implemented in v6 if you feel like implementing them. 48 | * Moderators can edit in locked topics 49 | * Add custom tags to autotagging 50 | * Read messages deleted while on topic 51 | * Filter out threads with titles matching custom phrases 52 | -------------------------------------------------------------------------------- /aops-enhanced.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name AoPS Enhanced 6 3 | // @namespace https://gitlab.com/epiccakeking 4 | // @match https://artofproblemsolving.com/* 5 | // @grant none 6 | // @version 6.2.0 7 | // @author epiccakeking 8 | // @description AoPS Enhanced adds and improves various features of the AoPS website. 9 | // @license MIT 10 | // @icon https://artofproblemsolving.com/online-favicon.ico?v=2 11 | // ==/UserScript== 12 | 13 | // Functions for settings UI elements 14 | let settings_ui = { 15 | toggle: label => (name, value, settings_manager) => { 16 | let checkbox_label = document.createElement('label'); 17 | let checkbox = document.createElement('input'); 18 | checkbox.type = 'checkbox'; 19 | checkbox.name = name; 20 | checkbox.checked = value; 21 | checkbox.addEventListener('change', e => settings_manager.set(name, e.target.checked)); 22 | checkbox_label.appendChild(checkbox); 23 | checkbox_label.appendChild(document.createTextNode(' ' + label)); 24 | return checkbox_label; 25 | }, 26 | select: (label, options) => (name, value, settings_manager) => { 27 | let select_label = document.createElement('label'); 28 | select_label.innerText = label + ' '; 29 | let select = document.createElement('select'); 30 | select.name = name; 31 | for (let option of options) { 32 | let option_element = document.createElement('option'); 33 | option_element.value = option[0]; 34 | option_element.innerText = option[1]; 35 | select.appendChild(option_element); 36 | } 37 | select.value = value; 38 | select.addEventListener('change', e => settings_manager.set(name, e.target.value)); 39 | select_label.appendChild(select); 40 | return select_label; 41 | }, 42 | } 43 | 44 | let themes = { 45 | 'None': '', 46 | 'Mobile': ` 47 | .cmty-bbcode-buttons{ 48 | display: block; 49 | height: auto; 50 | width: auto !important; 51 | } 52 | .cmty-posting-button-row{ 53 | height: min-content !important; 54 | display: flow-root; 55 | } 56 | #feed-wrapper{ 57 | display: inline; 58 | } 59 | #feed-topic{ 60 | top: 0; 61 | left: 0; 62 | width: 100%; 63 | height: 100%; 64 | } 65 | .cmty-no-tablet{ 66 | display: inline !important; 67 | } 68 | #feed-topic .cmty-topic-jump{ 69 | position: fixed; 70 | top: 32px; 71 | bottom: auto; 72 | left: auto; 73 | right: 10px; 74 | z-index: 1000; 75 | font-size: 24px; 76 | } 77 | #feed-topic .cmty-topic-jump-top{ 78 | right: 40px; 79 | } 80 | .cmty-upload-modal{ 81 | display: inline; 82 | } 83 | .aops-modal-body{ 84 | width: 100% !important; 85 | } 86 | 87 | #feed-tabs .cmty-postbox-inner-box{ 88 | width: 100% !important; 89 | max-width: none !important; 90 | } 91 | 92 | #feed-topic .cmty-topic-posts-outer-wrapper > .aops-scroll-outer > .aops-scroll-inner { 93 | left: 0; 94 | width: 100% !important; 95 | } 96 | 97 | #feed-topic .cmty-postbox-inner-box { 98 | max-width: 100% !important; 99 | } 100 | `, 101 | "Dark": ` 102 | .cmty-topic-jump{ 103 | color: #000; 104 | } 105 | *{ 106 | scrollbar-color: #04a3af #333533; 107 | } 108 | ::-webkit-scrollbar-track{ 109 | background: #333533; 110 | } 111 | ::-webkit-scrollbar-thumb{ 112 | background: #04a3af; 113 | } 114 | .cmty-topic-posts-top *:not(.cmty-item-tag){ 115 | color: black !important; 116 | } 117 | #page-wrapper img:not([class*='latex']):not([class*='asy']), 118 | #feed-wrapper img:not([class*='latex']):not([class*='asy']){ 119 | filter: hue-rotate(180deg) invert(1); 120 | } 121 | .bbcode_smiley[src*='latex']{ 122 | filter: none !important; 123 | } 124 | .cmty-topic-posts-top, 125 | .cmty-postbox-inner-box, 126 | .cmty-topic-posts-bottom, 127 | .aops-scroll-outer{ 128 | background: #ddd !important; 129 | } 130 | .aops-scroll-slider{ 131 | background: #222 !important; 132 | } 133 | iframe{ 134 | filter: invert(1) hue-rotate(180deg); 135 | } 136 | 137 | #page-wrapper, 138 | .aops-modal-wrapper, 139 | #feed-wrapper > * > *{ 140 | filter: invert(1) hue-rotate(180deg); 141 | } 142 | body{ 143 | background: #111 !important; 144 | }`, 145 | } 146 | 147 | let quote_schemes = { 148 | 'AoPS': AoPS.Community ? AoPS.Community.Views.Post.prototype.onClickQuote : function () { alert("Quoting failed") }, // Uses dummy function as a fallback when community is undefined. 149 | 'Enhanced': function () { this.topic.appendToReply("[quote name=\"" + this.model.get("username") + "\" url=\"/community/p" + this.model.get("post_id") + "\"]\n" + this.model.get("post_canonical").trim() + "\n[/quote]\n\n") }, 150 | 'Link': function () { this.topic.appendToReply(`@[url=https://aops.com/community/p${this.model.get("post_id")}]${this.model.get("username")} (#${this.model.get("post_number")}):[/url]`); }, 151 | 'Hide': function () { 152 | this.topic.appendToReply(`[hide=Post #${this.model.get("post_number")} by ${this.model.get("username")}] 153 | [url=https://aops.com/community/user/${this.model.get("poster_id")}]${this.model.get('username')}[/url] [url=https://aops.com/community/p${this.model.get("post_id")}](view original)[/url] 154 | ${this.model.get('post_canonical').trim()} 155 | [/hide] 156 | 157 | `); 158 | }, 159 | }; 160 | 161 | class EnhancedSettingsManager { 162 | /** Default settings */ 163 | DEFAULTS = { 164 | notifications: true, 165 | post_links: true, 166 | feed_moderation: true, 167 | kill_top: false, 168 | quote_primary: 'Enhanced', 169 | quote_secondary: 'Enhanced', 170 | theme: 'None', 171 | }; 172 | 173 | /** 174 | * Constructor 175 | * @param {string} storage_variable - Variable to use when reading or writing settings 176 | */ 177 | constructor(storage_variable) { 178 | this.storage_variable = storage_variable; 179 | this._settings = JSON.parse(localStorage.getItem(this.storage_variable) || '{}'); 180 | this.hooks = {}; 181 | } 182 | 183 | /** 184 | * Retrieves a setting. 185 | * @param {string} setting - Setting to retrieve 186 | */ 187 | get = setting => (setting in this._settings ? this._settings : this.DEFAULTS)[setting]; 188 | 189 | /** 190 | * Sets a setting. 191 | * @param {string} setting - Setting to change 192 | * @param {*} value - Value to set 193 | */ 194 | set(setting, value) { 195 | this._settings[setting] = value; 196 | localStorage.setItem(this.storage_variable, JSON.stringify(this._settings)); 197 | // Run hooks 198 | if (setting in this.hooks) for (let hook of this.hooks[setting]) hook(value); 199 | } 200 | 201 | /** 202 | * Add a hook that will be called when the associated setting is changed. 203 | * @param {string} setting - Setting to add a hook to 204 | * @param {function} callback - Callback to run when the setting is changed 205 | * @param {boolean} run_on_add - Whether to immediately run the hook 206 | */ 207 | add_hook(setting, callback, run_on_add = true) { 208 | setting in this.hooks ? this.hooks[setting].push(callback) : this.hooks[setting] = [callback]; 209 | if (run_on_add) callback(this.get(setting)); 210 | } 211 | 212 | // No functions for removing hooks, do it manually by modifying the hooks attribute. 213 | } 214 | 215 | let enhanced_settings = new EnhancedSettingsManager('enhanced_settings'); 216 | // Old settings adapter 217 | for (let setting of ['quote_primary', 'quote_secondary']) { 218 | let setting_value = enhanced_settings.get(setting); 219 | if (setting_value.toLowerCase() == setting_value) enhanced_settings.set(setting, setting_value[0].toUpperCase() + setting_value.slice(1)); 220 | } 221 | 222 | // Themes 223 | { 224 | const theme_element = document.createElement('style'); 225 | 226 | enhanced_settings.add_hook('theme', value => { 227 | theme_element.textContent = themes[value]; 228 | if (value != 'None') { 229 | document.head.appendChild(theme_element); 230 | } else if (theme_element.parentNode) theme_element.parentNode.removeChild(theme_element); 231 | window.dispatchEvent(new Event('resize')); // Recalculate sizes of elements 232 | }); 233 | } 234 | 235 | // Simplified header 236 | { 237 | const menubar_wrapper = document.querySelector('.menubar-links-outer'); 238 | const login_wrapper = document.querySelector('.menu-login-wrapper') 239 | if (!(menubar_wrapper && login_wrapper)) return _ => null; 240 | let kill_element = document.createElement('style'); 241 | const menubar_wrapper_normal_position = menubar_wrapper.nextSibling; 242 | const login_wrapper_normal_position = login_wrapper.nextSibling; 243 | kill_element.textContent = ` 244 | #header { 245 | display: none !important; 246 | } 247 | .menubar-links-outer { 248 | position: absolute; 249 | z-index: 1000; 250 | top: 0; 251 | right: 0; 252 | flex-direction: row-reverse; 253 | } 254 | 255 | .menubar-labels{ 256 | line-height: 10px; 257 | margin-right: 10px; 258 | } 259 | 260 | .menubar-label-link.selected{ 261 | color: #fff !important; 262 | } 263 | 264 | .menu-login-item { 265 | color: #fff !important; 266 | } 267 | #small-footer-wrapper { 268 | display: none !important; 269 | } 270 | .login-dropdown-divider { 271 | display:none !important; 272 | } 273 | .login-dropdown-content { 274 | padding: 12px 12px 12px !important; 275 | border-top: 2.4px #009fad solid; 276 | } 277 | 278 | .menu-login-wrapper .login-dropdown-label { 279 | color: #606060; 280 | } 281 | 282 | .menu-login-wrapper { 283 | height: 35px; 284 | margin-bottom: -35px; /* Hack to fix neighboring .site heights */ 285 | } 286 | `; 287 | 288 | enhanced_settings.add_hook('kill_top', value => { 289 | if (value) { 290 | document.getElementById('header-wrapper').before(menubar_wrapper); 291 | document.querySelector('.sharedsite-links > .site:last-child').after(login_wrapper); 292 | document.head.appendChild(kill_element); 293 | } else { 294 | menubar_wrapper_normal_position.before(menubar_wrapper); 295 | login_wrapper_normal_position.before(login_wrapper) 296 | if (kill_element.parentNode) kill_element.parentNode.removeChild(kill_element); 297 | } 298 | window.dispatchEvent(new Event('resize')); // Recalculate sizes of elements 299 | }) 300 | } 301 | 302 | // Feed moderator icon 303 | { 304 | const style = document.createElement('style'); 305 | style.textContent = '#feed-topic .cmty-topic-moderate{ display: inline !important; }'; 306 | enhanced_settings.add_hook('feed_moderation', value => { 307 | if (value) { 308 | document.head.appendChild(style); 309 | } else { 310 | if (style.parentNode) style.parentNode.removeChild(style); 311 | } 312 | }, true) 313 | } 314 | 315 | // Notifications 316 | { 317 | let notify_functions = [ 318 | AoPS.Ui.Flyout.display, 319 | a => { 320 | var textextract = document.createElement("div"); 321 | textextract.innerHTML = a.replace('
', '\n'); 322 | var y = $(textextract).text() 323 | var notification = new Notification("AoPS Enhanced", { body: y, icon: 'https://artofproblemsolving.com/online-favicon.ico', tag: y }); 324 | setTimeout(notification.close.bind(notification), 5000); 325 | } 326 | ]; 327 | 328 | enhanced_settings.add_hook('notifications', value => { 329 | if (value && Notification.permission != "granted") Notification.requestPermission(); 330 | AoPS.Ui.Flyout.display = notify_functions[+value]; 331 | }, true); 332 | } 333 | 334 | function show_enhanced_configurator() { 335 | UI_ELEMENTS = { 336 | notifications: settings_ui.toggle('Notifications'), 337 | post_links: settings_ui.toggle('Post links'), 338 | feed_moderation: settings_ui.toggle('Feed moderate icon'), 339 | kill_top: settings_ui.toggle('Simplify UI'), 340 | quote_primary: settings_ui.select('Primary quote', Object.keys(quote_schemes).map(k => [k, k])), 341 | quote_secondary: settings_ui.select('Ctrl quote', Object.keys(quote_schemes).map(k => [k, k])), 342 | theme: settings_ui.select('Theme', Object.keys(themes).map(k => [k, k])), 343 | } 344 | let settings_modal = document.createElement('div'); 345 | for (let key in UI_ELEMENTS) { 346 | settings_modal.appendChild(UI_ELEMENTS[key](key, enhanced_settings.get(key), enhanced_settings)); 347 | settings_modal.appendChild(document.createElement('br')); 348 | } 349 | alert(settings_modal); 350 | } 351 | 352 | // Add "Enhanced" option to login dropdown 353 | { 354 | const el = document.querySelector('.login-dropdown-content'); 355 | if (el === null) return; 356 | let enhanced_settings_element = document.createElement('a'); 357 | enhanced_settings_element.classList.add('menu-item'); 358 | enhanced_settings_element.innerText = 'Enhanced'; 359 | enhanced_settings_element.addEventListener('click', e => { e.preventDefault(); show_enhanced_configurator(); }); 360 | el.appendChild(enhanced_settings_element); 361 | } 362 | 363 | // Prevent errors when trying to modify AoPS Community on pages where it doesn't exist 364 | if (AoPS.Community) { 365 | AoPS.Community.Views.Post.prototype.onClickQuote = function (e) { 366 | quote_schemes[enhanced_settings.get(e.ctrlKey ? 'quote_secondary' : 'quote_primary')].call(this); 367 | }; 368 | 369 | // Direct links 370 | (() => { 371 | let real_onClickDirectLink = AoPS.Community.Views.Post.prototype.onClickDirectLink; 372 | function direct_link_function(e) { 373 | let url = 'https://aops.com/community/p' + this.model.get("post_id"); 374 | navigator.clipboard.writeText(url); 375 | AoPS.Ui.Flyout.display(`URL copied: ${url}`); 376 | } 377 | AoPS.Community.Views.Post.prototype.onClickDirectLink = function (e) { 378 | (enhanced_settings.get('post_links') ? direct_link_function : real_onClickDirectLink).call(this, e); 379 | } 380 | })(); 381 | } 382 | --------------------------------------------------------------------------------