|
|
| Line 1: |
Line 1: |
| /**
| |
| * MediaWiki Common JavaScript Module - Enhanced Version
| |
| * Unified version with Quest Tracker functionality integrated
| |
| * Designed for MediaWiki site usage with Vector Legacy skin support
| |
| *
| |
| * @author NewCora Wiki Team
| |
| * @version 3.0
| |
| * @license MIT
| |
| */
| |
|
| |
|
| (function() {
| |
| 'use strict';
| |
|
| |
| // ============================================================================
| |
| // CONFIGURATION & CONSTANTS
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Configuration object for the module
| |
| * @type {Object}
| |
| */
| |
| const CONFIG = {
| |
| SELECTORS: {
| |
| TAB_CONTENT: '.tab-content',
| |
| NAV_TAB: '.nav-tab, .nav-tab-fgod',
| |
| CARD_LINKS: '.card[data-link], .destaque-card[data-link]',
| |
| COLLAPSIBLE_HEADER: '.collapsible-header',
| |
| COLLAPSIBLE_BUTTON: '.collapsible',
| |
| GUARDIAN_ACCORDION: '.guardian-accordion',
| |
| // Quest Tracker selectors
| |
| QUEST_CHECKBOX: '.quest-checkbox',
| |
| NESTED_TAB: '.nested-tab',
| |
| NESTED_CONTENT: '.nested-content',
| |
| // Back to Top selectors
| |
| BTT_BUTTON: '.btt-button'
| |
| },
| |
| CLASSES: {
| |
| ACTIVE: 'active',
| |
| EXPANDED: 'expanded',
| |
| COLLAPSED: 'collapsed',
| |
| MOBILE_OPEN: 'mobile-open',
| |
| SIDEBAR_OPEN: 'sidebar-open',
| |
| RECOMMENDED: 'recommended',
| |
| // Back to Top classes
| |
| SHOW: 'show',
| |
| PULSE: 'pulse'
| |
| },
| |
| STORAGE_KEYS: {
| |
| SIDEBAR_PREFIX: 'sidebar-',
| |
| USER_MENU_STATE: 'user-menu-state',
| |
| QUEST_PROGRESS: 'shamanJiaQuestProgress',
| |
| USER_THEME: 'mw-user-theme'
| |
| },
| |
| DELAYS: {
| |
| SCROLL_DELAY: 100,
| |
| ACCORDION_SCROLL_DELAY: 300
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // UTILITY FUNCTIONS
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Safely gets an element by ID with error handling
| |
| * @param {string} id - The element ID
| |
| * @returns {Element|null} The element or null if not found
| |
| */
| |
| function safeGetElementById(id) {
| |
| try {
| |
| return document.getElementById(id);
| |
| } catch (error) {
| |
| console.warn(`Element with ID '${id}' not found:`, error);
| |
| return null;
| |
| }
| |
| }
| |
|
| |
| /**
| |
| * Safely queries for elements with error handling
| |
| * @param {string} selector - CSS selector
| |
| * @param {Element} [context=document] - Context element
| |
| * @returns {NodeList} NodeList of matching elements
| |
| */
| |
| function safeQuerySelectorAll(selector, context = document) {
| |
| try {
| |
| return context.querySelectorAll(selector);
| |
| } catch (error) {
| |
| console.warn(`Invalid selector '${selector}':`, error);
| |
| return [];
| |
| }
| |
| }
| |
|
| |
| /**
| |
| * Safely queries for a single element with error handling
| |
| * @param {string} selector - CSS selector
| |
| * @param {Element} [context=document] - Context element
| |
| * @returns {Element|null} The first matching element or null
| |
| */
| |
| function safeQuerySelector(selector, context = document) {
| |
| try {
| |
| return context.querySelector(selector);
| |
| } catch (error) {
| |
| console.warn(`Invalid selector '${selector}':`, error);
| |
| return null;
| |
| }
| |
| }
| |
|
| |
| /**
| |
| * Safely accesses localStorage with error handling
| |
| * @param {string} key - Storage key
| |
| * @param {string} [defaultValue=''] - Default value if key doesn't exist
| |
| * @returns {string} The stored value or default
| |
| */
| |
| function safeGetLocalStorage(key, defaultValue = '') {
| |
| try {
| |
| return localStorage.getItem(key) || defaultValue;
| |
| } catch (error) {
| |
| console.warn(`localStorage access failed for key '${key}':`, error);
| |
| return defaultValue;
| |
| }
| |
| }
| |
|
| |
| /**
| |
| * Safely sets localStorage with error handling
| |
| * @param {string} key - Storage key
| |
| * @param {string} value - Value to store
| |
| * @returns {boolean} Success status
| |
| */
| |
| function safeSetLocalStorage(key, value) {
| |
| try {
| |
| localStorage.setItem(key, value);
| |
| return true;
| |
| } catch (error) {
| |
| console.warn(`localStorage write failed for key '${key}':`, error);
| |
| return false;
| |
| }
| |
| }
| |
|
| |
| /**
| |
| * Checks if an element is in the viewport
| |
| * @param {Element} element - Element to check
| |
| * @returns {boolean} True if element is visible in viewport
| |
| */
| |
| function isElementInViewport(element) {
| |
| if (!element) return false;
| |
|
| |
| const rect = element.getBoundingClientRect();
| |
| return (
| |
| rect.top >= 0 &&
| |
| rect.left >= 0 &&
| |
| rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
| |
| rect.right <= (window.innerWidth || document.documentElement.clientWidth)
| |
| );
| |
| }
| |
|
| |
| /**
| |
| * Determines if a node is inside an excluded container
| |
| * @param {Node} node - The node to check
| |
| * @returns {boolean} True if node is in excluded context
| |
| */
| |
| function isInExcludedContext(node) {
| |
| if (!node || !node.parentElement) return false;
| |
|
| |
| const excludedSelectors = [
| |
| 'script', 'style', 'textarea', 'input', 'select', 'option',
| |
| '.ve-ui-surface', '.mw-editform', '.CodeMirror', '.cm-editor', '.ace_editor'
| |
| ].join(', ');
| |
|
| |
| return !!node.parentElement.closest(excludedSelectors);
| |
| }
| |
|
| |
| const ThemeManager = {
| |
| key: CONFIG.STORAGE_KEYS.USER_THEME,
| |
| current: 'light',
| |
| prefersDark: function() {
| |
| return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
| |
| },
| |
| load: function() {
| |
| const v = safeGetLocalStorage(this.key);
| |
| this.current = v || (this.prefersDark() ? 'dark' : 'light');
| |
| this.apply(this.current);
| |
| },
| |
| apply: function(theme) {
| |
| this.current = theme === 'dark' ? 'dark' : 'light';
| |
| const root = document.documentElement;
| |
| root.setAttribute('data-theme', this.current);
| |
| document.body.classList.toggle('theme-dark', this.current === 'dark');
| |
| safeSetLocalStorage(this.key, this.current);
| |
| this.updateToggleUI();
| |
| },
| |
| toggle: function() {
| |
| this.apply(this.current === 'dark' ? 'light' : 'dark');
| |
| },
| |
| mountToggle: function() {
| |
| if (document.getElementById('pt-theme-toggle')) return;
| |
| const render = () => {
| |
| const li = (window.mw && window.mw.util)
| |
| ? mw.util.addPortletLink('p-personal', '#', '', 'pt-theme-toggle', 'Alternar tema')
| |
| : null;
| |
| let anchor = li ? li.querySelector('a') : null;
| |
| if (!anchor) {
| |
| anchor = document.createElement('a');
| |
| anchor.id = 'pt-theme-toggle';
| |
| anchor.href = '#';
| |
| (document.body || document.documentElement).appendChild(anchor);
| |
| }
| |
| anchor.setAttribute('role', 'button');
| |
| anchor.setAttribute('aria-pressed', this.current === 'dark' ? 'true' : 'false');
| |
| anchor.setAttribute('aria-label', this.current === 'dark' ? 'Tema escuro' : 'Tema claro');
| |
| anchor.setAttribute('data-theme-toggle', 'true');
| |
| anchor.innerHTML = this.iconMarkup();
| |
| this.ensureDelegatedListener();
| |
| };
| |
| if (window.mw && mw.loader) {
| |
| mw.loader.using(['mediawiki.util']).then(render, render);
| |
| } else {
| |
| render();
| |
| }
| |
| },
| |
| ensureDelegatedListener: function() {
| |
| if (this.boundDocClick) return;
| |
| this.boundDocClick = (e) => {
| |
| const t = e.target.closest('[data-theme-toggle]');
| |
| if (!t) return;
| |
| e.preventDefault();
| |
| t.classList.add('pressed');
| |
| setTimeout(() => t.classList.remove('pressed'), 150);
| |
| this.toggle();
| |
| };
| |
| document.addEventListener('click', this.boundDocClick, false);
| |
| },
| |
| updateToggleUI: function() {
| |
| let anchor = document.querySelector('[data-theme-toggle]');
| |
| if (!anchor) {
| |
| const li = document.getElementById('pt-theme-toggle');
| |
| if (li) {
| |
| if (li.tagName === 'A') anchor = li; else anchor = li.querySelector('a');
| |
| }
| |
| }
| |
| if (!anchor) return;
| |
| anchor.setAttribute('aria-pressed', this.current === 'dark' ? 'true' : 'false');
| |
| anchor.setAttribute('aria-label', this.current === 'dark' ? 'Tema escuro' : 'Tema claro');
| |
| anchor.innerHTML = this.iconMarkup();
| |
| },
| |
| iconMarkup: function() {
| |
| const sun = '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm0-10a4 4 0 1 1 0 8 4 4 0 0 1 0-8zm0-6h1v3h-1V2zm0 17h1v3h-1v-3zM2 11h3v1H2v-1zm17 0h3v1h-3v-1zM4.22 4.22l.7-.7 2.12 2.12-.7.7L4.22 4.22zm12.74 12.74.7-.7 2.12 2.12-.7.7-2.12-2.12zM4.22 19.78l2.12-2.12.7.7-2.12 2.12-.7-.7zm12.74-12.74 2.12-2.12.7.7-2.12 2.12-.7-.7z"/></svg>';
| |
| const moon = '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>';
| |
| return '<span class="theme-toggle-icon">'+(this.current==='dark'?moon:sun)+'</span>';
| |
| },
| |
| selfTest: function(times = 5, delay = 150) {
| |
| const el = document.querySelector('[data-theme-toggle]');
| |
| let i = 0;
| |
| const run = () => {
| |
| if (!el || i >= times) { console.log('Theme toggle test complete'); return; }
| |
| el.click(); i++; setTimeout(run, delay);
| |
| };
| |
| run();
| |
| },
| |
| selfCheckLinks: function() {
| |
| const theme = document.documentElement.getAttribute('data-theme');
| |
| const report = (sel) => {
| |
| const el = document.querySelector(sel);
| |
| if (!el) return { selector: sel, present: false };
| |
| const cs = getComputedStyle(el);
| |
| return { selector: sel, present: true, color: cs.color };
| |
| };
| |
| const samples = [
| |
| '#mw-content-text a',
| |
| 'body.skin-vector-legacy #mw-panel .vector-menu-content a',
| |
| 'body.skin-vector-legacy #p-personal a'
| |
| ].map(report);
| |
| console.table({ theme, sample0: samples[0], sample1: samples[1], sample2: samples[2] });
| |
| },
| |
| init: function() {
| |
| this.load();
| |
| this.mountToggle();
| |
| this.ensureDelegatedListener();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // QUEST TRACKER MODULE (Integrated from ShamanGia)
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Quest Tracker Module
| |
| * Manages quest progress tracking with localStorage persistence
| |
| */
| |
| const QuestTracker = {
| |
| /**
| |
| * Initializes quest tracker functionality
| |
| */
| |
| init: function() {
| |
| // Only initialize if quest tracker elements exist
| |
| const questCheckboxes = safeQuerySelectorAll(CONFIG.SELECTORS.QUEST_CHECKBOX);
| |
| if (questCheckboxes.length === 0) {
| |
| return;
| |
| }
| |
|
| |
| this.loadQuestProgress();
| |
| this.setupEventListeners();
| |
| this.updateProgressDisplay();
| |
|
| |
| console.log('Quest Tracker initialized');
| |
| },
| |
|
| |
| /**
| |
| * Sets up event listeners for quest tracker
| |
| */
| |
| setupEventListeners: function() {
| |
| const questCheckboxes = safeQuerySelectorAll(CONFIG.SELECTORS.QUEST_CHECKBOX);
| |
| const resetButton = safeGetElementById('reset-progress');
| |
|
| |
| // Add event listeners to checkboxes
| |
| questCheckboxes.forEach(checkbox => {
| |
| checkbox.addEventListener('change', () => {
| |
| this.saveQuestProgress();
| |
| this.updateProgressDisplay();
| |
| });
| |
| });
| |
|
| |
| // Add event listener to reset button
| |
| if (resetButton) {
| |
| resetButton.addEventListener('click', () => {
| |
| if (confirm('Are you sure you want to reset all quest progress? This action cannot be undone.')) {
| |
| this.resetAllProgress();
| |
| }
| |
| });
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Loads quest progress from localStorage
| |
| */
| |
| loadQuestProgress: function() {
| |
| const savedProgress = safeGetLocalStorage(CONFIG.STORAGE_KEYS.QUEST_PROGRESS);
| |
| if (savedProgress) {
| |
| try {
| |
| const progressData = JSON.parse(savedProgress);
| |
| Object.keys(progressData).forEach(questId => {
| |
| const checkbox = safeQuerySelector(`[data-quest="${questId}"]`);
| |
| if (checkbox) {
| |
| checkbox.checked = progressData[questId];
| |
| }
| |
| });
| |
| } catch (error) {
| |
| console.error('Error loading quest progress:', error);
| |
| }
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Saves quest progress to localStorage
| |
| */
| |
| saveQuestProgress: function() {
| |
| const questCheckboxes = safeQuerySelectorAll(CONFIG.SELECTORS.QUEST_CHECKBOX);
| |
| const progressData = {};
| |
|
| |
| questCheckboxes.forEach(checkbox => {
| |
| const questId = checkbox.getAttribute('data-quest');
| |
| if (questId) {
| |
| progressData[questId] = checkbox.checked;
| |
| }
| |
| });
| |
|
| |
| try {
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.QUEST_PROGRESS, JSON.stringify(progressData));
| |
| } catch (error) {
| |
| console.error('Error saving quest progress:', error);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Updates progress display elements
| |
| */
| |
| updateProgressDisplay: function() {
| |
| const questCheckboxes = safeQuerySelectorAll(CONFIG.SELECTORS.QUEST_CHECKBOX);
| |
| const totalQuests = questCheckboxes.length;
| |
| let completedQuests = 0;
| |
|
| |
| questCheckboxes.forEach(checkbox => {
| |
| if (checkbox.checked) {
| |
| completedQuests++;
| |
| }
| |
| });
| |
|
| |
| const completionPercentage = totalQuests > 0 ? Math.round((completedQuests / totalQuests) * 100) : 0;
| |
|
| |
| // Update progress display elements
| |
| const totalProgressElement = safeGetElementById('total-progress');
| |
| const completionPercentageElement = safeGetElementById('completion-percentage');
| |
| const progressFillElement = safeGetElementById('progress-fill');
| |
|
| |
| if (totalProgressElement) {
| |
| totalProgressElement.textContent = `${completedQuests}/${totalQuests}`;
| |
| }
| |
|
| |
| if (completionPercentageElement) {
| |
| completionPercentageElement.textContent = `${completionPercentage}%`;
| |
| }
| |
|
| |
| if (progressFillElement) {
| |
| progressFillElement.style.width = `${completionPercentage}%`;
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Resets all quest progress
| |
| */
| |
| resetAllProgress: function() {
| |
| const questCheckboxes = safeQuerySelectorAll(CONFIG.SELECTORS.QUEST_CHECKBOX);
| |
|
| |
| // Uncheck all checkboxes
| |
| questCheckboxes.forEach(checkbox => {
| |
| checkbox.checked = false;
| |
| });
| |
|
| |
| // Clear localStorage
| |
| try {
| |
| localStorage.removeItem(CONFIG.STORAGE_KEYS.QUEST_PROGRESS);
| |
| } catch (error) {
| |
| console.error('Error clearing quest progress:', error);
| |
| }
| |
|
| |
| // Update progress display
| |
| this.updateProgressDisplay();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // TAB NAVIGATION SYSTEM (Enhanced with Quest Tracker support)
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Tab Navigation Module
| |
| * Handles tab switching functionality with smooth scrolling support
| |
| * Enhanced to work with both MediaWiki tabs and Quest Tracker nested tabs
| |
| */
| |
| const TabNavigation = {
| |
| /**
| |
| * Shows a specific tab and hides others
| |
| * @param {string} tabId - ID of the tab content to show
| |
| * @param {Element} [tabElement] - The tab button element
| |
| */
| |
| showTab: function(tabId, tabElement) {
| |
| if (!tabId) {
| |
| console.warn('TabNavigation.showTab: tabId is required');
| |
| return;
| |
| }
| |
|
| |
| // CRITICAL FIX: Hide ALL nested contents first when switching main tabs
| |
| // This prevents nested content from other sections remaining visible
| |
| const allNestedContents = document.querySelectorAll('.nested-content');
| |
| allNestedContents.forEach(content => {
| |
| content.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| });
| |
|
| |
| // Hide all tab contents
| |
| const contents = safeQuerySelectorAll(CONFIG.SELECTORS.TAB_CONTENT);
| |
| contents.forEach(content => content.classList.remove(CONFIG.CLASSES.ACTIVE));
| |
|
| |
| // Deactivate all tab buttons
| |
| const tabs = safeQuerySelectorAll(CONFIG.SELECTORS.NAV_TAB);
| |
| tabs.forEach(tab => tab.classList.remove(CONFIG.CLASSES.ACTIVE));
| |
|
| |
| // Show target tab content
| |
| const targetContent = safeGetElementById(tabId);
| |
| if (targetContent) {
| |
| targetContent.classList.add(CONFIG.CLASSES.ACTIVE);
| |
|
| |
| // If this tab has nested tabs, activate the first nested tab
| |
| if (tabId === 'tab-normal-daily' || tabId === 'tab-shadow-dailies' || tabId === 'tab-shaman-girl-jia') {
| |
| setTimeout(() => {
| |
| const firstNestedTab = targetContent.querySelector('.nested-tab');
| |
| if (firstNestedTab) {
| |
| const firstNestedTabId = firstNestedTab.getAttribute('data-tab');
| |
| if (firstNestedTabId) {
| |
| TabNavigation.showNestedTab(firstNestedTabId, firstNestedTab);
| |
| }
| |
| }
| |
| }, 50);
| |
| }
| |
| } else {
| |
| console.warn(`Tab content with ID '${tabId}' not found`);
| |
| }
| |
|
| |
| // Activate the clicked tab button
| |
| if (tabElement) {
| |
| tabElement.classList.add(CONFIG.CLASSES.ACTIVE);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Shows a specific nested tab and hides others
| |
| * @param {string} tabId - ID of the nested tab content to show
| |
| * @param {Element} [tabElement] - The nested tab button element
| |
| */
| |
| showNestedTab: function(tabId, tabElement) {
| |
| if (!tabId) {
| |
| console.warn('TabNavigation.showNestedTab: tabId is required');
| |
| return;
| |
| }
| |
|
| |
| const container = tabElement
| |
| ? (tabElement.closest('.nested-container, .token-card')
| |
| || tabElement.closest('.tokens-section')
| |
| || tabElement.closest('.tab-content')
| |
| || document)
| |
| : document;
| |
|
| |
| const nestedContents = container.querySelectorAll('.nested-content');
| |
| nestedContents.forEach(content => {
| |
| content.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| });
| |
|
| |
| const nestedTabs = container.querySelectorAll('.nested-tab');
| |
| nestedTabs.forEach(tab => {
| |
| tab.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| });
| |
|
| |
| let targetContent = null;
| |
| if (container && container.querySelector) {
| |
| targetContent = container.querySelector('#' + tabId);
| |
| }
| |
| if (!targetContent) {
| |
| targetContent = safeGetElementById(tabId);
| |
| }
| |
| if (targetContent) {
| |
| targetContent.classList.add(CONFIG.CLASSES.ACTIVE);
| |
| } else {
| |
| console.warn(`Nested tab content with ID '${tabId}' not found`);
| |
| }
| |
|
| |
| if (tabElement) {
| |
| tabElement.classList.add(CONFIG.CLASSES.ACTIVE);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Handles tab click events using event delegation
| |
| * @param {Event} event - Click event
| |
| */
| |
| handleTabClick: function(event) {
| |
| const tab = event.target.closest('.nav-tab[data-tab], .nav-tab-fgod[data-tab]');
| |
| if (!tab) return;
| |
|
| |
| event.preventDefault();
| |
| const targetId = tab.getAttribute('data-tab');
| |
|
| |
| if (targetId) {
| |
| TabNavigation.showTab(targetId, tab);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Handles nested tab click events using event delegation
| |
| * Enhanced to work with Quest Tracker nested tabs
| |
| * @param {Event} event - Click event
| |
| */
| |
| handleNestedTabClick: function(event) {
| |
| const nestedTab = event.target.closest('.nested-tab[data-tab]');
| |
| if (!nestedTab) return;
| |
|
| |
| event.preventDefault();
| |
| const targetId = nestedTab.getAttribute('data-tab');
| |
|
| |
| if (targetId) {
| |
| TabNavigation.showNestedTab(targetId, nestedTab);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Handles special navigation clicks with scroll support
| |
| * @param {Event} event - Click event
| |
| */
| |
| handleSpecialTabNavigation: function(event) {
| |
| const trigger = event.target.closest('[data-tab-trigger]');
| |
| if (!trigger) return;
| |
|
| |
| event.preventDefault();
| |
|
| |
| const tabData = trigger.getAttribute('data-tab-trigger');
| |
| const buttonId = trigger.getAttribute('data-tab-button');
| |
| const href = trigger.getAttribute('href');
| |
| const scrollToAttr = trigger.getAttribute('data-scroll-to');
| |
|
| |
| // Determine scroll target
| |
| let scrollTargetSelector = null;
| |
| if (scrollToAttr && scrollToAttr.trim()) {
| |
| scrollTargetSelector = '#' + scrollToAttr.replace(/^#/, '');
| |
| } else if (href && href.charAt(0) === '#') {
| |
| scrollTargetSelector = href;
| |
| }
| |
|
| |
| // Switch tab if specified
| |
| if (tabData && buttonId) {
| |
| const targetButton = safeGetElementById(buttonId);
| |
| if (targetButton) {
| |
| TabNavigation.showTab(tabData, targetButton);
| |
|
| |
| // Scroll to anchor after tab switch
| |
| if (scrollTargetSelector) {
| |
| const scrollTarget = safeQuerySelector(scrollTargetSelector);
| |
| if (scrollTarget) {
| |
| setTimeout(() => {
| |
| scrollTarget.scrollIntoView({ behavior: 'smooth' });
| |
| }, CONFIG.DELAYS.SCROLL_DELAY);
| |
| }
| |
| }
| |
| }
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Initializes tab navigation event listeners
| |
| */
| |
| init: function() {
| |
| document.addEventListener('click', this.handleTabClick, false);
| |
| document.addEventListener('click', this.handleNestedTabClick, false);
| |
| document.addEventListener('click', this.handleSpecialTabNavigation, false);
| |
|
| |
| // Make showTab globally available for backward compatibility
| |
| window.showTab = this.showTab;
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // BACK TO TOP MODULE
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Back to Top Module
| |
| * Manages the back-to-top button functionality with smooth animations
| |
| */
| |
| const BackToTop = {
| |
| button: null,
| |
| isVisible: false,
| |
| scrollThreshold: 300,
| |
|
| |
| /**
| |
| * Creates the back-to-top button element
| |
| */
| |
| createButton: function() {
| |
| const button = document.createElement('button');
| |
| button.className = 'btt-button';
| |
| button.setAttribute('aria-label', 'Voltar ao topo');
| |
| button.setAttribute('title', 'Voltar ao topo');
| |
| document.body.appendChild(button);
| |
| return button;
| |
| },
| |
|
| |
| /**
| |
| * Shows the back-to-top button with animation
| |
| */
| |
| showButton: function() {
| |
| if (!this.isVisible && this.button) {
| |
| this.button.classList.add(CONFIG.CLASSES.SHOW);
| |
| this.button.classList.add(CONFIG.CLASSES.PULSE);
| |
| this.isVisible = true;
| |
|
| |
| // Remove pulse class after animation
| |
| setTimeout(() => {
| |
| if (this.button) {
| |
| this.button.classList.remove(CONFIG.CLASSES.PULSE);
| |
| }
| |
| }, 6000);
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Hides the back-to-top button with animation
| |
| */
| |
| hideButton: function() {
| |
| if (this.isVisible && this.button) {
| |
| this.button.classList.remove(CONFIG.CLASSES.SHOW);
| |
| this.button.classList.remove(CONFIG.CLASSES.PULSE);
| |
| this.isVisible = false;
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Handles scroll events to show/hide button
| |
| */
| |
| handleScroll: function() {
| |
| const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
| |
|
| |
| if (scrollTop > this.scrollThreshold) {
| |
| this.showButton();
| |
| } else {
| |
| this.hideButton();
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Handles button click to scroll to top
| |
| */
| |
| handleClick: function() {
| |
| window.scrollTo({
| |
| top: 0,
| |
| behavior: 'smooth'
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes the back-to-top functionality
| |
| */
| |
| init: function() {
| |
| // Create the button
| |
| this.button = this.createButton();
| |
|
| |
| // Bind event handlers
| |
| const boundHandleScroll = this.handleScroll.bind(this);
| |
| const boundHandleClick = this.handleClick.bind(this);
| |
|
| |
| // Add event listeners
| |
| window.addEventListener('scroll', boundHandleScroll, { passive: true });
| |
| this.button.addEventListener('click', boundHandleClick);
| |
|
| |
| // Initial check
| |
| this.handleScroll();
| |
|
| |
| console.log('Back to Top button initialized');
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // NAVIGATION & INTERACTION HANDLERS (Enhanced)
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Navigation Handlers Module
| |
| * Manages various navigation interactions
| |
| */
| |
| const NavigationHandlers = {
| |
| /**
| |
| * Handles card click-through navigation
| |
| * @param {Event} event - Click event
| |
| */
| |
| handleCardClick: function(event) {
| |
| const card = event.target.closest(CONFIG.SELECTORS.CARD_LINKS);
| |
| if (!card) return;
| |
|
| |
| const link = card.getAttribute('data-link');
| |
| if (!link) return;
| |
|
| |
| // Handle in-page anchor navigation
| |
| if (link.charAt(0) === '#') {
| |
| event.preventDefault();
| |
| const anchorElement = safeQuerySelector(link);
| |
| if (anchorElement) {
| |
| anchorElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
| |
| }
| |
| return;
| |
| }
| |
|
| |
| // Handle absolute external URLs
| |
| if (/^https?:\/\//i.test(link)) {
| |
| window.location.href = link;
| |
| return;
| |
| }
| |
|
| |
| // Navigate to MediaWiki page
| |
| const targetUrl = (window.mw && window.mw.util && typeof window.mw.util.getUrl === 'function')
| |
| ? window.mw.util.getUrl(link)
| |
| : ('index.php?title=' + encodeURIComponent(link));
| |
|
| |
| window.location.href = targetUrl;
| |
| },
| |
|
| |
| /**
| |
| * Initializes navigation event handlers
| |
| */
| |
| init: function() {
| |
| document.addEventListener('click', this.handleCardClick, false);
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // WIKI LINK PROCESSOR
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Wiki Link Processor Module
| |
| * Converts [[Title]] markup to proper MediaWiki links
| |
| */
| |
| const WikiLinkProcessor = {
| |
| /**
| |
| * Processes MediaWiki-style internal link markup
| |
| * Converts [[Title]] or [[Title|Display]] to proper anchor tags
| |
| */
| |
| processWikiLinks: function() {
| |
| // Skip if no raw brackets found (already processed by MediaWiki)
| |
| if (!document.body || document.body.textContent.indexOf('[[') === -1) {
| |
| return;
| |
| }
| |
|
| |
| const walker = document.createTreeWalker(
| |
| document.body,
| |
| NodeFilter.SHOW_TEXT,
| |
| null,
| |
| false
| |
| );
| |
|
| |
| const textNodes = [];
| |
| let node;
| |
|
| |
| // Collect text nodes containing wiki links
| |
| while ((node = walker.nextNode())) {
| |
| const value = node.nodeValue;
| |
| if (!value || value.indexOf('[[') === -1 || isInExcludedContext(node)) {
| |
| continue;
| |
| }
| |
| textNodes.push(node);
| |
| }
| |
|
| |
| const linkRegex = /\[\[([^\|\]]+)(?:\|([^\]]+))?\]\]/g;
| |
|
| |
| // Process each text node
| |
| textNodes.forEach(textNode => {
| |
| const sourceText = textNode.nodeValue;
| |
| if (!linkRegex.test(sourceText)) return;
| |
|
| |
| linkRegex.lastIndex = 0; // Reset regex state
| |
|
| |
| const parent = textNode.parentNode;
| |
| const fragment = document.createDocumentFragment();
| |
| let lastIndex = 0;
| |
| let match;
| |
|
| |
| while ((match = linkRegex.exec(sourceText)) !== null) {
| |
| // Add text before the match
| |
| if (match.index > lastIndex) {
| |
| fragment.appendChild(
| |
| document.createTextNode(sourceText.slice(lastIndex, match.index))
| |
| );
| |
| }
| |
|
| |
| const rawTitle = (match[1] || '').trim();
| |
| const displayText = (match[2] != null ? match[2] : rawTitle).trim();
| |
|
| |
| // Skip File: or Image: links - keep original text
| |
| if (/^(File:|Image:)/i.test(rawTitle)) {
| |
| fragment.appendChild(document.createTextNode(match[0]));
| |
| } else {
| |
| // Remove leading colon if present
| |
| const cleanTitle = rawTitle.charAt(0) === ':' ? rawTitle.slice(1) : rawTitle;
| |
|
| |
| // Create proper MediaWiki link
| |
| const href = (window.mw && window.mw.util && typeof window.mw.util.getUrl === 'function')
| |
| ? window.mw.util.getUrl(cleanTitle)
| |
| : ('index.php?title=' + encodeURIComponent(cleanTitle));
| |
|
| |
| const anchor = document.createElement('a');
| |
| anchor.className = 'mw-link-internal';
| |
| anchor.setAttribute('href', href);
| |
| anchor.appendChild(document.createTextNode(displayText));
| |
| fragment.appendChild(anchor);
| |
| }
| |
|
| |
| lastIndex = linkRegex.lastIndex;
| |
| }
| |
|
| |
| // Add remaining text
| |
| if (lastIndex < sourceText.length) {
| |
| fragment.appendChild(
| |
| document.createTextNode(sourceText.slice(lastIndex))
| |
| );
| |
| }
| |
|
| |
| // Replace the original text node
| |
| parent.insertBefore(fragment, textNode);
| |
| parent.removeChild(textNode);
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes wiki link processing
| |
| */
| |
| init: function() {
| |
| if (document.readyState === 'loading') {
| |
| document.addEventListener('DOMContentLoaded', this.processWikiLinks);
| |
| } else {
| |
| this.processWikiLinks();
| |
| }
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // MOBILE INTERFACE
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Mobile Interface Module
| |
| * Handles mobile-specific UI components
| |
| */
| |
| const MobileInterface = {
| |
| /**
| |
| * Creates hamburger menu for mobile sidebar navigation
| |
| */
| |
| createHamburgerMenu: function() {
| |
| // Only create for Vector Legacy skin
| |
| if (!document.body.classList.contains('skin-vector-legacy')) {
| |
| return;
| |
| }
| |
|
| |
| const panel = safeGetElementById('mw-panel');
| |
| if (!panel) return;
| |
|
| |
| // Create hamburger button
| |
| const hamburger = document.createElement('button');
| |
| hamburger.className = 'mobile-hamburger-menu';
| |
| hamburger.setAttribute('aria-label', 'Toggle navigation menu');
| |
| hamburger.innerHTML = '<span></span><span></span><span></span>';
| |
|
| |
| // Insert hamburger at top of page
| |
| const content = safeGetElementById('content') || safeQuerySelector('.mw-body');
| |
| if (content && content.parentNode) {
| |
| content.parentNode.insertBefore(hamburger, content);
| |
| }
| |
|
| |
| // Toggle functionality
| |
| const toggleSidebar = () => {
| |
| panel.classList.toggle(CONFIG.CLASSES.MOBILE_OPEN);
| |
| hamburger.classList.toggle(CONFIG.CLASSES.ACTIVE);
| |
| document.body.classList.toggle(CONFIG.CLASSES.SIDEBAR_OPEN);
| |
| };
| |
|
| |
| // Event listeners
| |
| hamburger.addEventListener('click', (event) => {
| |
| event.preventDefault();
| |
| event.stopPropagation();
| |
| toggleSidebar();
| |
| });
| |
|
| |
| // Close on outside click
| |
| document.addEventListener('click', (event) => {
| |
| if (panel.classList.contains(CONFIG.CLASSES.MOBILE_OPEN) &&
| |
| !panel.contains(event.target) &&
| |
| !hamburger.contains(event.target)) {
| |
| panel.classList.remove(CONFIG.CLASSES.MOBILE_OPEN);
| |
| hamburger.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| document.body.classList.remove(CONFIG.CLASSES.SIDEBAR_OPEN);
| |
| }
| |
| });
| |
|
| |
| // Close on escape key
| |
| document.addEventListener('keydown', (event) => {
| |
| if (event.key === 'Escape' && panel.classList.contains(CONFIG.CLASSES.MOBILE_OPEN)) {
| |
| panel.classList.remove(CONFIG.CLASSES.MOBILE_OPEN);
| |
| hamburger.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| document.body.classList.remove(CONFIG.CLASSES.SIDEBAR_OPEN);
| |
| }
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes mobile interface components
| |
| */
| |
| init: function() {
| |
| this.createHamburgerMenu();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // COLLAPSIBLE SECTIONS
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Collapsible Sections Module
| |
| * Manages expandable/collapsible content sections
| |
| */
| |
| const CollapsibleSections = {
| |
| /**
| |
| * Initializes collapsible header functionality
| |
| */
| |
| initCollapsibleHeaders: function() {
| |
| const headers = safeQuerySelectorAll(CONFIG.SELECTORS.COLLAPSIBLE_HEADER);
| |
|
| |
| headers.forEach(header => {
| |
| header.addEventListener('click', (event) => {
| |
| event.preventDefault();
| |
|
| |
| const section = header.parentElement;
| |
| const content = section.querySelector('.collapsible-content');
| |
|
| |
| if (content) {
| |
| const isExpanded = section.classList.contains(CONFIG.CLASSES.EXPANDED);
| |
|
| |
| if (isExpanded) {
| |
| section.classList.remove(CONFIG.CLASSES.EXPANDED);
| |
| content.style.maxHeight = '0';
| |
| } else {
| |
| section.classList.add(CONFIG.CLASSES.EXPANDED);
| |
| content.style.maxHeight = content.scrollHeight + 'px';
| |
| }
| |
| }
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes collapsible button functionality
| |
| */
| |
| initCollapsibleButtons: function() {
| |
| const buttons = safeQuerySelectorAll(CONFIG.SELECTORS.COLLAPSIBLE_BUTTON);
| |
|
| |
| buttons.forEach(button => {
| |
| button.addEventListener('click', (event) => {
| |
| event.preventDefault();
| |
|
| |
| const content = button.nextElementSibling;
| |
|
| |
| if (content && content.classList.contains('collapsible-content')) {
| |
| const isActive = button.classList.contains(CONFIG.CLASSES.ACTIVE);
| |
|
| |
| if (isActive) {
| |
| button.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| content.style.maxHeight = '0';
| |
| } else {
| |
| button.classList.add(CONFIG.CLASSES.ACTIVE);
| |
| content.style.maxHeight = content.scrollHeight + 'px';
| |
| }
| |
| }
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes all collapsible section types
| |
| */
| |
| init: function() {
| |
| this.initCollapsibleHeaders();
| |
| this.initCollapsibleButtons();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // SIDEBAR MANAGEMENT
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Sidebar Management Module
| |
| * Handles Vector Legacy sidebar collapsible functionality
| |
| */
| |
| const SidebarManager = {
| |
| /**
| |
| * Gets a unique portal ID for localStorage
| |
| * @param {Element} portal - Portal element
| |
| * @returns {string} Unique portal identifier
| |
| */
| |
| getPortalId: function(portal) {
| |
| const heading = portal.querySelector('.vector-menu-heading, h3');
| |
| if (heading) {
| |
| return heading.textContent.trim().toLowerCase().replace(/\s+/g, '-');
| |
| }
| |
| return 'unknown-portal';
| |
| },
| |
|
| |
| /**
| |
| * Initializes sidebar collapsible functionality
| |
| */
| |
| initSidebarCollapsible: function() {
| |
| // Only for Vector Legacy skin
| |
| if (!document.body.classList.contains('skin-vector-legacy')) {
| |
| return;
| |
| }
| |
|
| |
| const panel = safeGetElementById('mw-panel');
| |
| if (!panel) return;
| |
|
| |
| const portals = safeQuerySelectorAll('.vector-menu-portal, .portal', panel);
| |
|
| |
| // Auto-collapse all sections except first (Navigation)
| |
| portals.forEach((portal, index) => {
| |
| if (index > 0) {
| |
| portal.classList.add(CONFIG.CLASSES.COLLAPSED);
| |
| const content = portal.querySelector('.vector-menu-content, .body');
| |
| if (content) {
| |
| content.style.maxHeight = '0';
| |
| }
| |
| }
| |
| });
| |
|
| |
| // Add click handlers to headings
| |
| const headings = safeQuerySelectorAll('.vector-menu-heading, .portal h3', panel);
| |
|
| |
| headings.forEach(heading => {
| |
| heading.addEventListener('click', (event) => {
| |
| event.preventDefault();
| |
| event.stopPropagation();
| |
|
| |
| const portal = heading.closest('.vector-menu-portal, .portal');
| |
| if (!portal) return;
| |
|
| |
| const content = portal.querySelector('.vector-menu-content, .body');
| |
| if (!content) return;
| |
|
| |
| const isCollapsed = portal.classList.contains(CONFIG.CLASSES.COLLAPSED);
| |
| const portalId = this.getPortalId(portal);
| |
|
| |
| if (isCollapsed) {
| |
| // Expand
| |
| portal.classList.remove(CONFIG.CLASSES.COLLAPSED);
| |
| content.style.maxHeight = content.scrollHeight + 'px';
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.SIDEBAR_PREFIX + portalId, 'expanded');
| |
| } else {
| |
| // Collapse
| |
| portal.classList.add(CONFIG.CLASSES.COLLAPSED);
| |
| content.style.maxHeight = '0';
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.SIDEBAR_PREFIX + portalId, 'collapsed');
| |
| }
| |
| });
| |
| });
| |
|
| |
| // Restore saved states
| |
| portals.forEach((portal, index) => {
| |
| if (index === 0) return; // Skip first portal
| |
|
| |
| const savedState = safeGetLocalStorage(CONFIG.STORAGE_KEYS.SIDEBAR_PREFIX + this.getPortalId(portal));
| |
| const content = portal.querySelector('.vector-menu-content, .body');
| |
|
| |
| if (savedState === 'expanded' && content) {
| |
| portal.classList.remove(CONFIG.CLASSES.COLLAPSED);
| |
| content.style.maxHeight = content.scrollHeight + 'px';
| |
| }
| |
| });
| |
|
| |
| // Recalculate heights on resize
| |
| window.addEventListener('resize', () => {
| |
| portals.forEach(portal => {
| |
| if (!portal.classList.contains(CONFIG.CLASSES.COLLAPSED)) {
| |
| const content = portal.querySelector('.vector-menu-content, .body');
| |
| if (content) {
| |
| content.style.maxHeight = content.scrollHeight + 'px';
| |
| }
| |
| }
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes sidebar management
| |
| */
| |
| init: function() {
| |
| this.initSidebarCollapsible();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // GUARDIAN DECISION HELPER
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Guardian Decision Helper Module
| |
| * Manages guardian type recommendation system
| |
| */
| |
| const GuardianDecisionHelper = {
| |
| /**
| |
| * Guardian type difficulty mapping
| |
| */
| |
| DIFFICULTY_MAP: {
| |
| '1': 'regular',
| |
| '2': 'mighty',
| |
| '3': 'legendary',
| |
| '4': 'superior',
| |
| '5': 'accomplished'
| |
| },
| |
|
| |
| /**
| |
| * Guardian type descriptions
| |
| */
| |
| TYPE_DESCRIPTIONS: {
| |
| 'regular': 'Perfect for beginners with limited time',
| |
| 'mighty': 'Good balance of effort and power',
| |
| 'legendary': 'Excellent for endgame content',
| |
| 'superior': 'For dedicated players seeking optimization',
| |
| 'accomplished': 'For true perfectionists'
| |
| },
| |
|
| |
| /**
| |
| * Initializes accordion functionality
| |
| */
| |
| initAccordion: function() {
| |
| const accordions = safeQuerySelectorAll(CONFIG.SELECTORS.GUARDIAN_ACCORDION);
| |
|
| |
| accordions.forEach(accordion => {
| |
| accordion.addEventListener('click', function() {
| |
| this.classList.toggle(CONFIG.CLASSES.ACTIVE);
| |
| const panel = this.nextElementSibling;
| |
|
| |
| if (panel) {
| |
| if (panel.style.maxHeight) {
| |
| panel.style.maxHeight = null;
| |
| } else {
| |
| panel.style.maxHeight = panel.scrollHeight + 'px';
| |
| }
| |
| }
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Shows guardian by difficulty level
| |
| * @param {string} level - Difficulty level (1-5)
| |
| */
| |
| showGuardianByDifficulty: function(level) {
| |
| const accordions = safeQuerySelectorAll(CONFIG.SELECTORS.GUARDIAN_ACCORDION);
| |
|
| |
| // Reset all accordions
| |
| accordions.forEach(accordion => {
| |
| accordion.classList.remove(CONFIG.CLASSES.ACTIVE);
| |
| const panel = accordion.nextElementSibling;
| |
| if (panel) {
| |
| panel.style.maxHeight = null;
| |
| }
| |
| });
| |
|
| |
| // Find and activate target accordion
| |
| const guardianType = this.DIFFICULTY_MAP[level];
| |
| if (guardianType) {
| |
| const targetAccordion = safeQuerySelector(`.${guardianType}-accordion`);
| |
| if (targetAccordion) {
| |
| targetAccordion.classList.add(CONFIG.CLASSES.ACTIVE);
| |
| const panel = targetAccordion.nextElementSibling;
| |
| if (panel) {
| |
| panel.style.maxHeight = panel.scrollHeight + 'px';
| |
| }
| |
|
| |
| // Scroll to accordion
| |
| setTimeout(() => {
| |
| targetAccordion.scrollIntoView({
| |
| behavior: 'smooth',
| |
| block: 'center'
| |
| });
| |
| }, CONFIG.DELAYS.ACCORDION_SCROLL_DELAY);
| |
| }
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Initializes decision helper functionality
| |
| */
| |
| initDecisionHelper: function() {
| |
| const options = safeQuerySelectorAll('.decision-options .option');
| |
|
| |
| options.forEach(option => {
| |
| option.addEventListener('click', function() {
| |
| // Remove selected class from siblings
| |
| const siblings = this.parentElement.querySelectorAll('.option');
| |
| siblings.forEach(sibling => sibling.classList.remove('selected'));
| |
|
| |
| // Add selected class to clicked option
| |
| this.classList.add('selected');
| |
|
| |
| // Update recommendation
| |
| GuardianDecisionHelper.updateRecommendation();
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Updates recommendation based on selected options
| |
| */
| |
| updateRecommendation: function() {
| |
| const selectedOptions = safeQuerySelectorAll('.decision-options .option.selected');
| |
| const recommendedTypeElement = safeGetElementById('recommendedType');
| |
|
| |
| if (!recommendedTypeElement) return;
| |
|
| |
| // Show default message if no options selected
| |
| if (selectedOptions.length === 0) {
| |
| recommendedTypeElement.textContent = 'Select options above';
| |
| return;
| |
| }
| |
|
| |
| // Count votes for each guardian type
| |
| const votes = {
| |
| 'regular': 0,
| |
| 'mighty': 0,
| |
| 'legendary': 0,
| |
| 'superior': 0,
| |
| 'accomplished': 0
| |
| };
| |
|
| |
| // Tally votes from selected options
| |
| selectedOptions.forEach(option => {
| |
| const types = option.getAttribute('data-type');
| |
| if (types) {
| |
| types.split(',').forEach(type => {
| |
| if (votes.hasOwnProperty(type.trim())) {
| |
| votes[type.trim()]++;
| |
| }
| |
| });
| |
| }
| |
| });
| |
|
| |
| // Find type with most votes
| |
| let maxVotes = 0;
| |
| let recommendedType = '';
| |
| let tiedTypes = [];
| |
|
| |
| for (const type in votes) {
| |
| if (votes[type] > maxVotes) {
| |
| maxVotes = votes[type];
| |
| recommendedType = type;
| |
| tiedTypes = [type];
| |
| } else if (votes[type] === maxVotes && maxVotes > 0) {
| |
| tiedTypes.push(type);
| |
| }
| |
| }
| |
|
| |
| // Handle ties by choosing more challenging type
| |
| if (tiedTypes.length > 1) {
| |
| const difficultyOrder = ['regular', 'mighty', 'legendary', 'superior', 'accomplished'];
| |
| let highestDifficultyIndex = -1;
| |
|
| |
| tiedTypes.forEach(type => {
| |
| const typeIndex = difficultyOrder.indexOf(type);
| |
| if (typeIndex > highestDifficultyIndex) {
| |
| highestDifficultyIndex = typeIndex;
| |
| recommendedType = type;
| |
| }
| |
| });
| |
| }
| |
|
| |
| // Format recommendation text
| |
| const formattedType = recommendedType.charAt(0).toUpperCase() + recommendedType.slice(1) + ' Guardian';
| |
| const description = this.TYPE_DESCRIPTIONS[recommendedType] || '';
| |
| const recommendationText = `${formattedType} - ${description}`;
| |
|
| |
| // Update display
| |
| recommendedTypeElement.textContent = recommendationText;
| |
| this.highlightRecommendedType(recommendedType);
| |
| },
| |
|
| |
| /**
| |
| * Highlights the recommended guardian type
| |
| * @param {string} type - Guardian type to highlight
| |
| */
| |
| highlightRecommendedType: function(type) {
| |
| // Remove highlight from all accordions
| |
| const accordions = safeQuerySelectorAll(CONFIG.SELECTORS.GUARDIAN_ACCORDION);
| |
| accordions.forEach(accordion => {
| |
| accordion.classList.remove(CONFIG.CLASSES.RECOMMENDED);
| |
| });
| |
|
| |
| // Add highlight to recommended type
| |
| const recommendedAccordion = safeQuerySelector(`.${type}-accordion`);
| |
| if (recommendedAccordion) {
| |
| recommendedAccordion.classList.add(CONFIG.CLASSES.RECOMMENDED);
| |
|
| |
| // Scroll to recommended type if not visible
| |
| if (!isElementInViewport(recommendedAccordion)) {
| |
| recommendedAccordion.scrollIntoView({
| |
| behavior: 'smooth',
| |
| block: 'center'
| |
| });
| |
| }
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Initializes Guardian Decision Helper
| |
| */
| |
| init: function() {
| |
| // Only initialize if guardian elements exist
| |
| const hasGuardianElements =
| |
| safeQuerySelector(CONFIG.SELECTORS.GUARDIAN_ACCORDION) ||
| |
| safeQuerySelector('.decision-options');
| |
|
| |
| if (hasGuardianElements) {
| |
| this.initAccordion();
| |
| this.initDecisionHelper();
| |
|
| |
| // Make functions globally available
| |
| window.updateGuardianRecommendation = this.updateRecommendation.bind(this);
| |
| window.showGuardianByDifficulty = this.showGuardianByDifficulty.bind(this);
| |
| }
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // USER MENU MANAGEMENT
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * User Menu Management Module
| |
| * Handles collapsible user menu functionality
| |
| */
| |
| const UserMenuManager = {
| |
| /**
| |
| * Initializes user menu collapsible functionality
| |
| */
| |
| initUserMenuCollapsible: function() {
| |
| // Only for Vector Legacy skin
| |
| if (!document.body.classList.contains('skin-vector-legacy')) {
| |
| return;
| |
| }
| |
|
| |
| const personalTools = safeGetElementById('p-personal');
| |
| if (!personalTools) return;
| |
|
| |
| // Get user menu items
| |
| const userMenuItems = [
| |
| 'pt-userpage', 'pt-mytalk', 'pt-preferences',
| |
| 'pt-watchlist', 'pt-mycontris', 'pt-logout'
| |
| ].map(id => safeGetElementById(id)).filter(Boolean);
| |
|
| |
| if (userMenuItems.length === 0) return;
| |
|
| |
| // Create menu container
| |
| const menuContainer = document.createElement('div');
| |
| menuContainer.id = 'user-menu-collapsible';
| |
| menuContainer.className = 'user-menu-container';
| |
|
| |
| // Create header/toggle button
| |
| const menuHeader = document.createElement('div');
| |
| menuHeader.className = 'user-menu-header';
| |
| menuHeader.textContent = 'User';
| |
|
| |
| menuHeader.addEventListener('click', () => {
| |
| menuContainer.classList.toggle(CONFIG.CLASSES.EXPANDED);
| |
|
| |
| const state = menuContainer.classList.contains(CONFIG.CLASSES.EXPANDED)
| |
| ? 'expanded' : 'collapsed';
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.USER_MENU_STATE, state);
| |
| });
| |
|
| |
| // Create content container
| |
| const menuContent = document.createElement('div');
| |
| menuContent.className = 'user-menu-content';
| |
|
| |
| const menuList = document.createElement('ul');
| |
| menuList.className = 'user-menu-list';
| |
|
| |
| // Move user menu items to new container
| |
| userMenuItems.forEach(item => {
| |
| if (item && item.parentNode) {
| |
| menuList.appendChild(item);
| |
| }
| |
| });
| |
|
| |
| // Assemble menu
| |
| menuContent.appendChild(menuList);
| |
| menuContainer.appendChild(menuHeader);
| |
| menuContainer.appendChild(menuContent);
| |
| document.body.appendChild(menuContainer);
| |
|
| |
| // Restore saved state
| |
| const savedState = safeGetLocalStorage(CONFIG.STORAGE_KEYS.USER_MENU_STATE);
| |
| if (savedState === 'expanded') {
| |
| menuContainer.classList.add(CONFIG.CLASSES.EXPANDED);
| |
| }
| |
|
| |
| // Close menu on outside click
| |
| document.addEventListener('click', (event) => {
| |
| if (menuContainer.classList.contains(CONFIG.CLASSES.EXPANDED) &&
| |
| !menuContainer.contains(event.target)) {
| |
| menuContainer.classList.remove(CONFIG.CLASSES.EXPANDED);
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.USER_MENU_STATE, 'collapsed');
| |
| }
| |
| });
| |
|
| |
| // Hover functionality
| |
| menuContainer.addEventListener('mouseenter', () => {
| |
| menuContainer.classList.add(CONFIG.CLASSES.EXPANDED);
| |
| });
| |
|
| |
| menuContainer.addEventListener('mouseleave', () => {
| |
| menuContainer.classList.remove(CONFIG.CLASSES.EXPANDED);
| |
| safeSetLocalStorage(CONFIG.STORAGE_KEYS.USER_MENU_STATE, 'collapsed');
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Initializes user menu management
| |
| */
| |
| init: function() {
| |
| this.initUserMenuCollapsible();
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // RESPONSIVE TABLE MANAGER
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Responsive Table Manager Module
| |
| * Wraps tables in containers for viewports ≤ 550px with enhanced accessibility and performance
| |
| */
| |
| const ResponsiveTableManager = {
| |
| /**
| |
| * Table selectors to make responsive
| |
| */
| |
| TABLE_SELECTORS: [
| |
| 'table.wikitable',
| |
| 'table.mw-datatable',
| |
| 'table.faq-table',
| |
| 'table.daily-table'
| |
| ],
| |
|
| |
| /**
| |
| * Viewport breakpoint for responsive behavior
| |
| */
| |
| RESPONSIVE_BREAKPOINT: 550,
| |
|
| |
| /**
| |
| * Checks if viewport width is ≤ 550px
| |
| * @returns {boolean} True if viewport requires responsive table behavior
| |
| */
| |
| isResponsiveViewport: function() {
| |
| return window.innerWidth <= this.RESPONSIVE_BREAKPOINT;
| |
| },
| |
|
| |
| /**
| |
| * Wraps a table in a container for responsive viewports
| |
| * @param {HTMLElement} table - Table element to wrap
| |
| */
| |
| wrapTable: function(table) {
| |
| // Skip if already wrapped or not on responsive viewport
| |
| if (table.closest('.table-container') || !this.isResponsiveViewport()) {
| |
| return;
| |
| }
| |
|
| |
| // Create wrapper with enhanced accessibility
| |
| const wrapper = document.createElement('div');
| |
| wrapper.className = 'table-container';
| |
| wrapper.setAttribute('role', 'region');
| |
| wrapper.setAttribute('aria-label', 'Tabela com rolagem horizontal');
| |
| wrapper.setAttribute('tabindex', '0');
| |
|
| |
| // Insert wrapper before table
| |
| table.parentNode.insertBefore(wrapper, table);
| |
|
| |
| // Move table into wrapper
| |
| wrapper.appendChild(table);
| |
|
| |
| // Add keyboard navigation support
| |
| this.addKeyboardNavigation(wrapper);
| |
| },
| |
|
| |
| /**
| |
| * Unwraps tables when viewport becomes larger
| |
| * @param {HTMLElement} table - Table element to unwrap
| |
| */
| |
| unwrapTable: function(table) {
| |
| const wrapper = table.closest('.table-container');
| |
| if (wrapper && !this.isResponsiveViewport()) {
| |
| // Move table out of wrapper
| |
| wrapper.parentNode.insertBefore(table, wrapper);
| |
| // Remove wrapper
| |
| wrapper.remove();
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Adds keyboard navigation support to table containers
| |
| * @param {HTMLElement} wrapper - Table wrapper element
| |
| */
| |
| addKeyboardNavigation: function(wrapper) {
| |
| wrapper.addEventListener('keydown', (event) => {
| |
| const { key } = event;
| |
| const scrollAmount = 100;
| |
|
| |
| switch (key) {
| |
| case 'ArrowLeft':
| |
| event.preventDefault();
| |
| wrapper.scrollLeft -= scrollAmount;
| |
| break;
| |
| case 'ArrowRight':
| |
| event.preventDefault();
| |
| wrapper.scrollLeft += scrollAmount;
| |
| break;
| |
| case 'Home':
| |
| event.preventDefault();
| |
| wrapper.scrollLeft = 0;
| |
| break;
| |
| case 'End':
| |
| event.preventDefault();
| |
| wrapper.scrollLeft = wrapper.scrollWidth;
| |
| break;
| |
| }
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Processes all tables based on viewport size with performance optimization
| |
| */
| |
| processAllTables: function() {
| |
| // Use requestAnimationFrame for better performance
| |
| requestAnimationFrame(() => {
| |
| this.TABLE_SELECTORS.forEach(selector => {
| |
| const tables = document.querySelectorAll(selector);
| |
| tables.forEach(table => {
| |
| if (this.isResponsiveViewport()) {
| |
| this.wrapTable(table);
| |
| } else {
| |
| this.unwrapTable(table);
| |
| }
| |
| });
| |
| });
| |
| });
| |
| },
| |
|
| |
| /**
| |
| * Handles window resize events with debouncing
| |
| */
| |
| handleResize: function() {
| |
| // Clear existing timeout
| |
| if (this.resizeTimeout) {
| |
| clearTimeout(this.resizeTimeout);
| |
| }
| |
|
| |
| // Debounce resize handling for better performance
| |
| this.resizeTimeout = setTimeout(() => {
| |
| this.processAllTables();
| |
| }, 100);
| |
| },
| |
|
| |
| /**
| |
| * Sets up mutation observer to handle dynamically added tables with performance optimization
| |
| */
| |
| setupMutationObserver: function() {
| |
| const observer = new MutationObserver((mutations) => {
| |
| // Batch process mutations for better performance
| |
| const tablesToProcess = [];
| |
|
| |
| mutations.forEach((mutation) => {
| |
| mutation.addedNodes.forEach((node) => {
| |
| if (node.nodeType === Node.ELEMENT_NODE) {
| |
| // Check if the added node is a table or contains tables
| |
| const tables = node.matches && this.TABLE_SELECTORS.some(selector => node.matches(selector))
| |
| ? [node]
| |
| : node.querySelectorAll ? node.querySelectorAll(this.TABLE_SELECTORS.join(', ')) : [];
| |
|
| |
| tablesToProcess.push(...tables);
| |
| }
| |
| });
| |
| });
| |
|
| |
| // Process all found tables in a single animation frame
| |
| if (tablesToProcess.length > 0) {
| |
| requestAnimationFrame(() => {
| |
| tablesToProcess.forEach(table => {
| |
| if (this.isResponsiveViewport()) {
| |
| this.wrapTable(table);
| |
| }
| |
| });
| |
| });
| |
| }
| |
| });
| |
|
| |
| observer.observe(document.body, {
| |
| childList: true,
| |
| subtree: true
| |
| });
| |
|
| |
| // Store observer reference for cleanup if needed
| |
| this.mutationObserver = observer;
| |
| },
| |
|
| |
| /**
| |
| * Initializes the responsive table system with enhanced MediaWiki compatibility
| |
| */
| |
| init: function() {
| |
| // Wait for MediaWiki to be ready
| |
| if (typeof mw !== 'undefined' && mw.loader) {
| |
| mw.loader.using(['mediawiki.util'], () => {
| |
| this.initializeSystem();
| |
| });
| |
| } else {
| |
| // Fallback for non-MediaWiki environments
| |
| this.initializeSystem();
| |
| }
| |
| },
| |
|
| |
| /**
| |
| * Core initialization logic
| |
| */
| |
| initializeSystem: function() {
| |
| // Process existing tables
| |
| this.processAllTables();
| |
|
| |
| // Set up resize handler with passive listener for better performance
| |
| window.addEventListener('resize', this.handleResize.bind(this), { passive: true });
| |
|
| |
| // Set up mutation observer for dynamic content
| |
| this.setupMutationObserver();
| |
|
| |
| // Add CSS class to body to indicate responsive tables are active
| |
| document.body.classList.add('responsive-tables-enabled');
| |
|
| |
| console.log('Responsive Table Manager initialized');
| |
| }
| |
| };
| |
|
| |
| const ImageLightbox = {
| |
| overlay: null,
| |
| bodyOverflow: '',
| |
| init: function() {
| |
| document.addEventListener('click', this.handleDocumentClick.bind(this), false);
| |
| },
| |
| buildOverlay: function() {
| |
| const overlay = document.createElement('div');
| |
| overlay.className = 'image-lightbox-overlay';
| |
| const theme = document.documentElement.getAttribute('data-theme') || 'light';
| |
| const bg = theme === 'dark' ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.7)';
| |
| overlay.style.position = 'fixed';
| |
| overlay.style.top = '0';
| |
| overlay.style.right = '0';
| |
| overlay.style.bottom = '0';
| |
| overlay.style.left = '0';
| |
| overlay.style.display = 'flex';
| |
| overlay.style.alignItems = 'center';
| |
| overlay.style.justifyContent = 'center';
| |
| overlay.style.background = bg;
| |
| overlay.style.zIndex = '9999';
| |
| overlay.style.padding = '2vw';
| |
| overlay.style.cursor = 'zoom-out';
| |
| overlay.setAttribute('role', 'dialog');
| |
| overlay.setAttribute('aria-modal', 'true');
| |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
| |
| document.addEventListener('keydown', this.handleKeydown);
| |
| return overlay;
| |
| },
| |
| handleKeydown: function(e) {
| |
| if (e.key === 'Escape') {
| |
| const self = ImageLightbox;
| |
| if (self.overlay) { self.close(); }
| |
| }
| |
| },
| |
| findImageFromTarget: function(target) {
| |
| const el = target.closest('img, .image-container, .image-grid img');
| |
| if (!el) return null;
| |
| if (el.tagName && el.tagName.toLowerCase() === 'img') return el;
| |
| const img = el.querySelector('img');
| |
| return img || null;
| |
| },
| |
| handleDocumentClick: function(e) {
| |
| if (this.overlay && this.overlay.contains(e.target)) return;
| |
| const img = this.findImageFromTarget(e.target);
| |
| if (!img) return;
| |
| e.preventDefault();
| |
| e.stopPropagation();
| |
| this.open(img);
| |
| },
| |
| open: function(img) {
| |
| if (this.overlay) return;
| |
| this.overlay = this.buildOverlay();
| |
| this.bodyOverflow = document.body.style.overflow || '';
| |
| document.body.style.overflow = 'hidden';
| |
| const content = document.createElement('div');
| |
| content.style.position = 'relative';
| |
| content.style.maxWidth = '90vw';
| |
| content.style.maxHeight = '90vh';
| |
| const clone = document.createElement('img');
| |
| clone.src = img.currentSrc || img.src;
| |
| clone.alt = img.alt || '';
| |
| clone.style.maxWidth = '90vw';
| |
| clone.style.maxHeight = '90vh';
| |
| clone.style.borderRadius = '12px';
| |
| clone.style.boxShadow = '0 10px 30px rgba(0,0,0,0.25)';
| |
| clone.style.transform = 'scale(0.98)';
| |
| clone.style.opacity = '0';
| |
| clone.style.transition = 'transform 150ms ease, opacity 150ms ease';
| |
| clone.style.willChange = 'transform, opacity';
| |
| content.appendChild(clone);
| |
| const captionText = this.getCaptionText(img);
| |
| if (captionText) {
| |
| const caption = document.createElement('div');
| |
| caption.textContent = captionText;
| |
| caption.style.marginTop = '0.75rem';
| |
| caption.style.fontSize = '0.9rem';
| |
| caption.style.textAlign = 'center';
| |
| const theme = document.documentElement.getAttribute('data-theme') || 'light';
| |
| caption.style.color = theme === 'dark' ? '#a8c6e8' : '#666';
| |
| content.appendChild(caption);
| |
| }
| |
| this.overlay.appendChild(content);
| |
| document.body.appendChild(this.overlay);
| |
| requestAnimationFrame(() => {
| |
| clone.style.transform = 'scale(1.25)';
| |
| clone.style.opacity = '1';
| |
| });
| |
| },
| |
| getCaptionText: function(img) {
| |
| const container = img.closest('.image-container');
| |
| const capEl = container ? container.querySelector('.image-caption') : null;
| |
| if (capEl && capEl.textContent) {
| |
| return capEl.textContent.trim();
| |
| }
| |
| return img.alt ? img.alt.trim() : '';
| |
| },
| |
| close: function() {
| |
| if (!this.overlay) return;
| |
| document.removeEventListener('keydown', this.handleKeydown);
| |
| if (this.overlay.parentNode) this.overlay.parentNode.removeChild(this.overlay);
| |
| this.overlay = null;
| |
| document.body.style.overflow = this.bodyOverflow;
| |
| }
| |
| };
| |
|
| |
| // ============================================================================
| |
| // MAIN INITIALIZATION
| |
| // ============================================================================
| |
|
| |
| /**
| |
| * Main initialization function
| |
| * Initializes all modules when DOM is ready
| |
| */
| |
| function initializeAll() {
| |
| try {
| |
| ThemeManager.init();
| |
| // Initialize core MediaWiki modules
| |
| TabNavigation.init();
| |
| NavigationHandlers.init();
| |
| WikiLinkProcessor.init();
| |
| MobileInterface.init();
| |
| CollapsibleSections.init();
| |
| SidebarManager.init();
| |
| GuardianDecisionHelper.init();
| |
| UserMenuManager.init();
| |
| ResponsiveTableManager.init();
| |
|
| |
| // Initialize Quest Tracker module
| |
| QuestTracker.init();
| |
|
| |
| // Initialize Back to Top module
| |
| BackToTop.init();
| |
| ImageLightbox.init();
| |
|
| |
| console.log('MediaWiki Common JavaScript with Quest Tracker and Back to Top initialized successfully');
| |
| } catch (error) {
| |
| console.error('Error initializing MediaWiki Common JavaScript:', error);
| |
| }
| |
| }
| |
|
| |
| // Initialize when DOM is ready
| |
| if (document.readyState === 'loading') {
| |
| document.addEventListener('DOMContentLoaded', initializeAll);
| |
| } else {
| |
| initializeAll();
| |
| }
| |
|
| |
| // Make Quest Tracker functions globally available for backward compatibility
| |
| window.initQuestTracker = QuestTracker.init.bind(QuestTracker);
| |
| window.loadQuestProgress = QuestTracker.loadQuestProgress.bind(QuestTracker);
| |
| window.saveQuestProgress = QuestTracker.saveQuestProgress.bind(QuestTracker);
| |
| window.updateProgressDisplay = QuestTracker.updateProgressDisplay.bind(QuestTracker);
| |
| window.resetAllProgress = QuestTracker.resetAllProgress.bind(QuestTracker);
| |
| window.__testThemeToggle = ThemeManager.selfTest.bind(ThemeManager);
| |
|
| |
| })();
| |