MediaWiki:Common.js: Difference between revisions

Current answers. Practical procedures. Reliable reference.
Jump to navigation Jump to search
Temporarily remove sidebar Discord CTA
Add cookie preference banner
Line 287: Line 287:
}
}
}() );
}() );
/* EDFM cookie preferences banner. First-party only; no build step. */
(function () {
'use strict';
var STORAGE_KEY = 'edfmCookieConsent.v1';
var ANALYTICS_COOKIE = 'edfmCookieAnalytics';
function parseChoice() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
catch (e) { return null; }
}
function setAnalyticsCookie(enabled) {
var maxAge = enabled ? 60 * 60 * 24 * 180 : 0;
document.cookie = ANALYTICS_COOKIE + '=' + (enabled ? '1' : '') + '; Max-Age=' + maxAge + '; Path=/; Secure; SameSite=Lax';
if (!enabled) {
document.cookie = 'edfmPopularSearch=; Max-Age=0; Path=/; Secure; SameSite=Lax';
}
}
function saveChoice(analytics) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ necessary: true, analytics: !!analytics, updated: new Date().toISOString() }));
setAnalyticsCookie(!!analytics);
}
function bannerHtml() {
return '<div class="edfm-cookie-card" role="dialog" aria-live="polite" aria-label="Cookie preferences">' +
'<div class="edfm-cookie-title">Cookie preferences</div>' +
'<div class="edfm-cookie-text">EDFM uses necessary cookies for login/security. Optional site-improvement cookies help Trending Guides understand broad searches like “Guardian stuff” without storing visitor IPs or usernames in the trending tables.</div>' +
'<div class="edfm-cookie-options" hidden><label><input type="checkbox" class="edfm-cookie-analytics"> Allow optional site-improvement cookies</label></div>' +
'<div class="edfm-cookie-actions">' +
'<button type="button" class="edfm-cookie-accept-all">Accept all</button>' +
'<button type="button" class="edfm-cookie-some">Accept some</button>' +
'<button type="button" class="edfm-cookie-reject">Reject optional</button>' +
'</div>' +
'<div class="edfm-cookie-links"><a href="/wiki/Elite_Dangerous_Field_Manual:Cookie_Policy">Cookie Policy</a> · <a href="/wiki/Elite_Dangerous_Field_Manual:Privacy_Policy">Privacy Policy</a></div>' +
'</div>';
}
function showBanner() {
if (document.getElementById('edfm-cookie-banner')) { return; }
var wrap = document.createElement('div');
wrap.id = 'edfm-cookie-banner';
wrap.innerHTML = bannerHtml();
document.body.appendChild(wrap);
var options = wrap.querySelector('.edfm-cookie-options');
var checkbox = wrap.querySelector('.edfm-cookie-analytics');
wrap.querySelector('.edfm-cookie-accept-all').addEventListener('click', function () { saveChoice(true); wrap.remove(); });
wrap.querySelector('.edfm-cookie-reject').addEventListener('click', function () { saveChoice(false); wrap.remove(); });
wrap.querySelector('.edfm-cookie-some').addEventListener('click', function () {
if (options.hidden) { options.hidden = false; checkbox.focus(); return; }
saveChoice(checkbox.checked); wrap.remove();
});
}
function initCookiePrefs() {
var choice = parseChoice();
if (!choice) { showBanner(); return; }
setAnalyticsCookie(!!choice.analytics);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCookiePrefs);
} else {
initCookiePrefs();
}
window.edfmShowCookiePreferences = showBanner;
}());

Revision as of 21:50, 14 August 2026

/* EDFM: small, purely presentational enhancements. None are required
 * for the site to function -- all degrade gracefully with JS off -- and
 * none are achievable in pure CSS, which is why they exist here instead
 * of in MediaWiki:Common.css. Kept deliberately tiny: no framework, no
 * build step, vanilla DOM APIs only.
 */
( function () {
	'use strict';

	/* 1. Eyebrow placement (see Template:Article header, .edfm-eyebrow in
	 * Common.css). MediaWiki always renders the real #firstHeading (the
	 * page's H1) before the article body in the DOM -- there is no
	 * template or CSS hook that lets wikitext content appear earlier than
	 * that. The eyebrow is authored as ordinary wikitext at the top of the
	 * article; this just moves that already-rendered, already-accessible
	 * element to sit visually above the title instead of below it. If this
	 * script doesn't run for any reason, the eyebrow still renders --
	 * directly under the title rather than above it -- so nothing breaks,
	 * this only affects placement.
	 */
	function placeEyebrow() {
		var eyebrow = document.querySelector( '.edfm-eyebrow' );
		var heading = document.getElementById( 'firstHeading' );
		if ( eyebrow && heading && heading.parentNode ) {
			heading.parentNode.insertBefore( eyebrow, heading );
		}
	}

	/* 2. "You are here" sidebar highlighting. MediaWiki's default sidebar
	 * has no built-in current-page indication -- this compares each
	 * sidebar link's URL to the current page and flags the match so
	 * Common.css can style it (see .edfm-nav-current). Wrapped in try/catch
	 * per-link only so one malformed href can't stop the rest from being
	 * checked.
	 */
	function highlightCurrentNavLink() {
		var links = document.querySelectorAll( '#mw-panel .vector-menu-content-list a[href]' );
		/* Compare pathname *and* query string, not just pathname. Most
		 * content pages use clean /wiki/Page_Name URLs where pathname alone
		 * is unambiguous, but action views (edit, history, diff, page
		 * info, ...) all route through the same /index.php pathname and
		 * are only distinguished by their query string (?action=edit vs
		 * ?action=info etc.) -- comparing pathname only would wrongly
		 * highlight every sidebar link that happens to also point at
		 * /index.php, e.g. "Page information" while actually viewing the
		 * edit-source screen.
		 */
		var here = window.location.pathname + window.location.search;
		for ( var i = 0; i < links.length; i++ ) {
			var a = links[ i ];
			try {
				var target = new URL( a.href, window.location.href );
				if ( target.pathname + target.search === here ) {
					a.parentNode.classList.add( 'edfm-nav-current' );
				}
			} catch ( e ) {
				/* malformed href on this one link -- skip it, not fatal */
			}
		}
	}

	/* 3. Jump-nav label shortening (2026-08-08, Engineer article layout
	 * pass). MediaWiki's native table of contents always mirrors a
	 * section's real ==Heading== text exactly -- there's no template or
	 * markup mechanism for a heading to carry a separate "short nav
	 * label". Article headings stay full and descriptive (e.g. "How to
	 * Receive an Invitation") since that's what reads well in the article
	 * body; this only shortens the *jump-nav tile* text afterwards, so the
	 * dense tile grid doesn't force long headings to wrap across two
	 * lines. Keyed by exact heading text, shared across all Engineer
	 * articles (same section names throughout, see Template:New engineer
	 * page) rather than being specific to any one page. Anything not in
	 * this list is left exactly as MediaWiki rendered it -- this only ever
	 * shortens, never invents nav text for a heading it doesn't recognise.
	 */
	var TOC_SHORT_LABELS = {
		'How to Discover This Engineer': 'Discovery',
		'How to Receive an Invitation': 'Invitation',
		'How to Unlock This Engineer': 'Unlock',
		'Engineer Unlock Chain': 'Unlock Chain',
		'Available Engineering': 'Engineering',
		'Experimental Effects': 'Experimentals',
		'Increasing Engineer Reputation': 'Reputation',
		'Recommended Blueprint to Pin': 'Blueprint Pinning'
	};

	function shortenTocLabels() {
		var toctexts = document.querySelectorAll( '#toc .toctext, .toc .toctext' );
		for ( var i = 0; i < toctexts.length; i++ ) {
			var el = toctexts[ i ];
			var full = el.textContent.trim();
			if ( Object.prototype.hasOwnProperty.call( TOC_SHORT_LABELS, full ) ) {
				el.textContent = TOC_SHORT_LABELS[ full ];
			}
		}
	}


	/* 4. Desktop top-bar clocks. RuneScape.wiki has a compact utility strip
	 * in the personal-toolbar tier; EDFM only needs time readouts there.
	 * UTC is synced once from MediaWiki's API curtimestamp (server-side UTC)
	 * and then advanced locally each second; local time uses the visitor's
	 * browser timezone. If the API request fails, the UTC clock falls back to
	 * the browser clock in UTC rather than leaving a blank control.
	 */
	function initTopBarClocks() {
		var personal = document.getElementById( 'p-personal' );
		if ( !personal || document.querySelector( '.edfm-topbar-clocks' ) ) {
			return;
		}

		var clocks = document.createElement( 'div' );
		clocks.className = 'edfm-topbar-clocks';
		clocks.setAttribute( 'aria-label', 'Current time' );

		var utcClock = document.createElement( 'span' );
		utcClock.className = 'edfm-topbar-clock edfm-topbar-clock-utc';
		utcClock.innerHTML = '<span class="edfm-topbar-clock-label">UTC</span> <time>--:--</time>';

		var localClock = document.createElement( 'span' );
		localClock.className = 'edfm-topbar-clock edfm-topbar-clock-local';
		localClock.innerHTML = '<span class="edfm-topbar-clock-label">Local</span> <time>--:--</time>';

		clocks.appendChild( utcClock );
		clocks.appendChild( localClock );
		personal.insertBefore( clocks, personal.firstChild );

		var utcOffset = 0;
		var utcTime = utcClock.querySelector( 'time' );
		var localTime = localClock.querySelector( 'time' );

		function twoDigits( number ) {
			return String( number ).padStart( 2, '0' );
		}
		function render() {
			var localNow = new Date();
			var utcNow = new Date( Date.now() + utcOffset );
			var utcText = twoDigits( utcNow.getUTCHours() ) + ':' + twoDigits( utcNow.getUTCMinutes() );
			var localText = twoDigits( localNow.getHours() ) + ':' + twoDigits( localNow.getMinutes() );

			utcTime.textContent = utcText;
			utcTime.setAttribute( 'datetime', utcNow.toISOString() );
			utcTime.setAttribute( 'title', 'Server time (UTC): ' + utcNow.toUTCString() );

			localTime.textContent = localText;
			localTime.setAttribute( 'datetime', localNow.toISOString() );
			localTime.setAttribute( 'title', 'Your local time: ' + localNow.toLocaleString() );
		}

		function syncServerTime() {
			if ( !window.fetch || !window.mw || !mw.util ) {
				render();
				return;
			}
			var apiUrl = mw.util.wikiScript( 'api' ) + '?action=query&format=json&curtimestamp=1';
			fetch( apiUrl, { credentials: 'same-origin' } )
				.then( function ( response ) { return response.json(); } )
				.then( function ( data ) {
					if ( data && data.curtimestamp ) {
						utcOffset = Date.parse( data.curtimestamp ) - Date.now();
					}
					render();
				} )
				.catch( render );
		}

		syncServerTime();
		window.setInterval( render, 1000 );
	}


	/* 6. Frontier news links. The Frontier feed uses normal MediaWiki external
	 * link wikitext so it remains valid when regenerated by the updater. Add
	 * target/rel in JS because MediaWiki wikitext does not support per-link
	 * target attributes without unsafe raw HTML.
	 */
	function initFrontierNewsExternalLinks() {
		var links = document.querySelectorAll( '.edfm-frontier-news .edfm-news-title a[href]' );
		for ( var i = 0; i < links.length; i++ ) {
			var link = links[ i ];
			link.target = '_blank';
			link.rel = 'nofollow noopener noreferrer';
		}
	}


	/* 7. Mobile app shell. Vector legacy was built for desktop; this adds a
	 * small progressive-enhancement shell on phones: hamburger drawer using
	 * the real #mw-panel links, a search button that reveals/focuses the
	 * existing MediaWiki search input, ESC/scrim close behavior, and ARIA
	 * state updates. With JS off the CSS fallback remains the existing stacked
	 * Vector mobile layout.
	 */
	function initMobileChrome() {
		var navigation = document.getElementById( 'mw-navigation' );
		var panel = document.getElementById( 'mw-panel' );
		var searchInput = document.getElementById( 'searchInput' );
		if ( !navigation || !panel || document.querySelector( '.edfm-mobile-appbar' ) ) {
			return;
		}

		var appbar = document.createElement( 'div' );
		appbar.className = 'edfm-mobile-appbar';

		var menuButton = document.createElement( 'button' );
		menuButton.type = 'button';
		menuButton.className = 'edfm-mobile-menu-button';
		menuButton.setAttribute( 'aria-label', 'Open navigation menu' );
		menuButton.setAttribute( 'aria-controls', 'mw-panel' );
		menuButton.setAttribute( 'aria-expanded', 'false' );

		var brand = document.createElement( 'a' );
		brand.className = 'edfm-mobile-brand';
		brand.href = mw && mw.util ? mw.util.getUrl( 'Main Page' ) : '/wiki/Main_Page';
		brand.textContent = 'Field Manual';

		var searchButton = document.createElement( 'button' );
		searchButton.type = 'button';
		searchButton.className = 'edfm-mobile-search-button';
		searchButton.setAttribute( 'aria-label', 'Search the Field Manual' );
		searchButton.setAttribute( 'aria-expanded', 'false' );

		appbar.appendChild( menuButton );
		appbar.appendChild( brand );
		appbar.appendChild( searchButton );
		navigation.insertBefore( appbar, navigation.firstChild );

		var closeButton = document.createElement( 'button' );
		closeButton.type = 'button';
		closeButton.className = 'edfm-mobile-close-button';
		closeButton.setAttribute( 'aria-label', 'Close navigation menu' );
		panel.insertBefore( closeButton, panel.firstChild );

		var scrim = document.createElement( 'button' );
		scrim.type = 'button';
		scrim.className = 'edfm-mobile-scrim';
		scrim.setAttribute( 'aria-label', 'Close navigation menu' );
		document.body.appendChild( scrim );

		function setMenuOpen( open ) {
			document.body.classList.toggle( 'edfm-mobile-menu-open', open );
			menuButton.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
		}
		function setSearchOpen( open ) {
			document.body.classList.toggle( 'edfm-mobile-search-open', open );
			searchButton.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
			if ( open && searchInput ) {
				window.setTimeout( function () { searchInput.focus(); }, 0 );
			}
		}

		menuButton.addEventListener( 'click', function () { setMenuOpen( true ); } );
		closeButton.addEventListener( 'click', function () { setMenuOpen( false ); } );
		scrim.addEventListener( 'click', function () { setMenuOpen( false ); } );
		searchButton.addEventListener( 'click', function () {
			setSearchOpen( !document.body.classList.contains( 'edfm-mobile-search-open' ) );
		} );
		panel.addEventListener( 'click', function ( event ) {
			if ( event.target && event.target.closest && event.target.closest( 'a' ) ) {
				setMenuOpen( false );
			}
		} );
		document.addEventListener( 'keydown', function ( event ) {
			if ( event.key === 'Escape' ) {
				setMenuOpen( false );
				setSearchOpen( false );
			}
		} );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', function () {
			placeEyebrow();
			highlightCurrentNavLink();
			shortenTocLabels();
			initTopBarClocks();
			initFrontierNewsExternalLinks();
			initMobileChrome();
		} );
	} else {
		placeEyebrow();
		highlightCurrentNavLink();
		shortenTocLabels();
		initTopBarClocks();
		initFrontierNewsExternalLinks();
		initMobileChrome();
	}
}() );
/* EDFM cookie preferences banner. First-party only; no build step. */
(function () {
	'use strict';
	var STORAGE_KEY = 'edfmCookieConsent.v1';
	var ANALYTICS_COOKIE = 'edfmCookieAnalytics';
	function parseChoice() {
		try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
		catch (e) { return null; }
	}
	function setAnalyticsCookie(enabled) {
		var maxAge = enabled ? 60 * 60 * 24 * 180 : 0;
		document.cookie = ANALYTICS_COOKIE + '=' + (enabled ? '1' : '') + '; Max-Age=' + maxAge + '; Path=/; Secure; SameSite=Lax';
		if (!enabled) {
			document.cookie = 'edfmPopularSearch=; Max-Age=0; Path=/; Secure; SameSite=Lax';
		}
	}
	function saveChoice(analytics) {
		localStorage.setItem(STORAGE_KEY, JSON.stringify({ necessary: true, analytics: !!analytics, updated: new Date().toISOString() }));
		setAnalyticsCookie(!!analytics);
	}
	function bannerHtml() {
		return '<div class="edfm-cookie-card" role="dialog" aria-live="polite" aria-label="Cookie preferences">' +
			'<div class="edfm-cookie-title">Cookie preferences</div>' +
			'<div class="edfm-cookie-text">EDFM uses necessary cookies for login/security. Optional site-improvement cookies help Trending Guides understand broad searches like “Guardian stuff” without storing visitor IPs or usernames in the trending tables.</div>' +
			'<div class="edfm-cookie-options" hidden><label><input type="checkbox" class="edfm-cookie-analytics"> Allow optional site-improvement cookies</label></div>' +
			'<div class="edfm-cookie-actions">' +
			'<button type="button" class="edfm-cookie-accept-all">Accept all</button>' +
			'<button type="button" class="edfm-cookie-some">Accept some</button>' +
			'<button type="button" class="edfm-cookie-reject">Reject optional</button>' +
			'</div>' +
			'<div class="edfm-cookie-links"><a href="/wiki/Elite_Dangerous_Field_Manual:Cookie_Policy">Cookie Policy</a> · <a href="/wiki/Elite_Dangerous_Field_Manual:Privacy_Policy">Privacy Policy</a></div>' +
			'</div>';
	}
	function showBanner() {
		if (document.getElementById('edfm-cookie-banner')) { return; }
		var wrap = document.createElement('div');
		wrap.id = 'edfm-cookie-banner';
		wrap.innerHTML = bannerHtml();
		document.body.appendChild(wrap);
		var options = wrap.querySelector('.edfm-cookie-options');
		var checkbox = wrap.querySelector('.edfm-cookie-analytics');
		wrap.querySelector('.edfm-cookie-accept-all').addEventListener('click', function () { saveChoice(true); wrap.remove(); });
		wrap.querySelector('.edfm-cookie-reject').addEventListener('click', function () { saveChoice(false); wrap.remove(); });
		wrap.querySelector('.edfm-cookie-some').addEventListener('click', function () {
			if (options.hidden) { options.hidden = false; checkbox.focus(); return; }
			saveChoice(checkbox.checked); wrap.remove();
		});
	}
	function initCookiePrefs() {
		var choice = parseChoice();
		if (!choice) { showBanner(); return; }
		setAnalyticsCookie(!!choice.analytics);
	}
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', initCookiePrefs);
	} else {
		initCookiePrefs();
	}
	window.edfmShowCookiePreferences = showBanner;
}());