MediaWiki:Gadget-pegasus.js: Difference between revisions

From PegasusXI Wiki
Vana'diel clock, theme toggle and Discord panel
 
Render the live Discord member list; fix header button placement
Line 248: Line 248:
/* ---------------------------------------------------------------- Discord panel
/* ---------------------------------------------------------------- Discord panel
*
*
* The official embedded widget needs "Enable Server Widget" turned on in Discord's server
* Discord's own embed (discord.com/widget) is a cross-origin iframe locked to Discord's blurple
* settings; while it is off, widget.json answers 403 and the iframe renders an error. So this
* palette, so it cannot be themed to match this wiki. The member list is therefore fetched and
* shows a join panel instead, which works either way and needs no API call.
* rendered here instead: widget.json sends Access-Control-Allow-Origin for this host, so no proxy
* is involved and the markup is ours to style.
*
* That endpoint answers 403 whenever "Enable Server Widget" is switched off in Discord's server
* settings, which is why the static join panel is still kept as a fallback rather than deleted.
*/
*/
var DISCORD_INVITE = 'https://discord.gg/M3NFvpm75E';
var DISCORD_INVITE = 'https://discord.gg/M3NFvpm75E';
var DISCORD_GUILD = '1473308284349059126';
var DISCORD_WIDGET = 'https://discord.com/api/guilds/' + DISCORD_GUILD + '/widget.json';
// Online first, then idle, then do-not-disturb, alphabetically within each.
var STATUS_RANK = { online: 0, idle: 1, dnd: 2, offline: 3 };
function statusRank( status ) {
return STATUS_RANK[ status ] === undefined ? STATUS_RANK.offline : STATUS_RANK[ status ];
}
function discordMember( member ) {
var status = STATUS_RANK[ member.status ] === undefined ? 'offline' : member.status;
var children = [];
if ( member.avatar_url ) {
children.push( el( 'img', { 'class': 'pg-discord-avatar', src: member.avatar_url,
alt: '', width: '20', height: '20', loading: 'lazy' } ) );
} else {
children.push( el( 'span', { 'class': 'pg-discord-avatar pg-discord-avatar-blank',
'aria-hidden': 'true' } ) );
}
children.push( el( 'span', { 'class': 'pg-discord-member-name', text: member.username } ) );
var li = el( 'li', { 'class': 'pg-discord-member', 'data-status': status }, children );
// The status is conveyed by a colour swatch, so name it for anyone not seeing colour.
li.setAttribute( 'title', member.game && member.game.name ?
member.username + ' - ' + status + ' - playing ' + member.game.name :
member.username + ' - ' + status );
return li;
}


function buildDiscordPanel() {
function buildDiscordPanel() {
return el( 'div', { id: 'discord-panel' }, [
var count = el( 'span', { 'class': 'pg-discord-count', text: 'Loading' } );
el( 'div', { 'class': 'pg-discord-fallback' }, [
var list = el( 'ul', { 'class': 'pg-discord-members' } );
el( 'p', { 'class': 'pg-discord-name', text: 'PegasusXI' } ),
 
el( 'p', { 'class': 'pg-discord-counts',
var panel = el( 'div', { 'class': 'pg-discord' }, [
text: 'Chat, support and server announcements' } ),
el( 'div', { 'class': 'pg-discord-head' }, [
el( 'a', { 'class': 'pg-discord-join', href: DISCORD_INVITE,
el( 'span', { 'class': 'pg-discord-title', text: 'PegasusXI' } ),
target: '_blank', rel: 'noopener', text: 'Join Discord' } )
count
] )
] ),
list,
el( 'a', { 'class': 'pg-discord-join', href: DISCORD_INVITE,
target: '_blank', rel: 'noopener', text: 'Join Discord' } )
] );
] );
fetch( DISCORD_WIDGET, { headers: { Accept: 'application/json' } } )
.then( function ( res ) {
if ( !res.ok ) {
throw new Error( 'HTTP ' + res.status );
}
return res.json();
} )
.then( function ( data ) {
var members = ( ( data && data.members ) || [] ).slice();
members.sort( function ( a, b ) {
return statusRank( a.status ) - statusRank( b.status ) ||
String( a.username ).localeCompare( String( b.username ) );
} );
// presence_count counts everyone online; the members array is capped by Discord.
var online = Number( data && data.presence_count );
if ( !isFinite( online ) ) {
online = members.length;
}
count.textContent = online + ( online === 1 ? ' member online' : ' members online' );
members.forEach( function ( member ) {
list.appendChild( discordMember( member ) );
} );
panel.setAttribute( 'data-state', 'ready' );
} )
.catch( function () {
// Most likely the server widget was switched off again. Fall back to the plain join
// panel rather than leaving "Loading" on screen for good.
panel.setAttribute( 'data-state', 'unavailable' );
count.textContent = 'Chat, support and server announcements';
} );
return el( 'div', { id: 'discord-panel' }, [ panel ] );
}
}


/* ---------------------------------------------------------------- header button */
/* ---------------------------------------------------------------- header button
*
* #mw-header is a flex row and Timeless assigns explicit orders: #p-logo-text 1, #p-search 2,
* #user-tools 3. A new child with no order defaults to 0 and jumps ahead of all of them, which is
* why this button first appeared at the far left of the header. MediaWiki:Timeless.css gives it
* order 4 so it sits at the right-hand end.
*/


function buildHeaderButton() {
function buildHeaderButton() {
return el( 'div', { id: 'p-discord-signin' }, [
return el( 'div', { id: 'p-discord-signin' }, [
el( 'a', { 'class': 'pg-discord-signin', href: DISCORD_INVITE,
el( 'a', { 'class': 'pg-discord-signin', href: DISCORD_INVITE,
target: '_blank', rel: 'noopener', text: 'Join Discord' } )
target: '_blank', rel: 'noopener' }, [
el( 'span', { 'class': 'pg-discord-signin-mark', 'aria-hidden': 'true' } ),
el( 'span', { text: 'Join Discord' } )
] )
] );
] );
}
}

Revision as of 23:36, 29 August 2026

/* PegasusXI Wiki sidebar additions: Vana'diel clock, theme toggle, Discord panel.
 *
 * The Node renderer this wiki replaced built all three server-side, so they were correct on first
 * paint and worked without JavaScript. MediaWiki has no hook for injecting arbitrary markup into a
 * Timeless sidebar chunk without a skin or extension, so they are built client-side here instead.
 * The trade-off is a brief gap before the panel appears; in exchange nothing about the wiki has to be
 * patched on disk, and every piece is editable on-wiki.
 *
 * Markup deliberately mirrors Timeless's own nesting -- .sidebar-chunk > h2 > span, then
 * .sidebar-inner > .mw-portlet > h3 + .mw-portlet-body -- because both the skin's collapse behaviour
 * at narrow widths and MediaWiki:Timeless.css key off that exact structure.
 */
( function () {
	'use strict';

	/* ---------------------------------------------------------------- Vana'diel clock math
	 *
	 * Time runs 25x Earth time. The anchor is Vana'diel's birthday: Earth 2002-06-23T15:00:00Z
	 * corresponds to Vana'diel 0898-02-01 00:00, which is (898 * 360) + 30 days after the epoch.
	 * The calendar has no leap years and no short months -- 360-day years, 30-day months, 8-day
	 * weeks -- so everything else is modulo arithmetic on Vana'diel minutes.
	 *
	 * The moon runs a separate 84-day cycle offset 26 days from the epoch. Phase comes from the
	 * percentage *and* the direction of travel, because each percentage occurs twice per cycle.
	 *
	 * Kept identical to site/public/vanadiel.mjs, which was cross-checked against HorizonXI's
	 * MediaWiki:VanaTime.js over a full moon cycle.
	 */
	var EARTH_ANCHOR_MS = 1024844400000;
	var VANA_ANCHOR_DAYS = 898 * 360 + 30;
	var RATE = 25;

	var MIN_PER_HOUR = 60;
	var MIN_PER_DAY = 1440;
	var MIN_PER_WEEK = 8 * MIN_PER_DAY;
	var MIN_PER_MONTH = 30 * MIN_PER_DAY;
	var MIN_PER_YEAR = 360 * MIN_PER_DAY;

	var MOON_CYCLE_DAYS = 84;
	var MOON_OFFSET_DAYS = 26;

	var WEEKDAYS = [
		'Firesday', 'Earthsday', 'Watersday', 'Windsday',
		'Iceday', 'Lightningday', 'Lightsday', 'Darksday'
	];
	var MOON_PHASES = [
		'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
		'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'
	];
	var MOON_ICONS = [ '\uD83C\uDF11', '\uD83C\uDF12', '\uD83C\uDF13', '\uD83C\uDF14',
		'\uD83C\uDF15', '\uD83C\uDF16', '\uD83C\uDF17', '\uD83C\uDF18' ];

	function moonPhaseIndex( percent, direction ) {
		// direction: 0 = turning point, 1 = falling toward new, 2 = rising toward full.
		if ( percent <= 5 || ( percent <= 10 && direction === 1 ) ) { return 0; }
		if ( percent >= 7 && percent <= 38 && direction === 2 ) { return 1; }
		if ( percent >= 40 && percent <= 55 && direction === 2 ) { return 2; }
		if ( percent >= 57 && percent <= 88 && direction === 2 ) { return 3; }
		if ( percent >= 95 || ( percent >= 90 && direction === 2 ) ) { return 4; }
		if ( percent >= 62 && percent <= 93 && direction === 1 ) { return 5; }
		if ( percent >= 45 && percent <= 60 && direction === 1 ) { return 6; }
		return 7;
	}

	function vanadiel() {
		var minutes = VANA_ANCHOR_DAYS * MIN_PER_DAY +
			( ( Date.now() - EARTH_ANCHOR_MS ) * RATE ) / 60000;

		var moonDay = Math.floor( ( ( minutes / MIN_PER_DAY ) + MOON_OFFSET_DAYS ) % MOON_CYCLE_DAYS );
		var moonPercent = Math.abs( Math.round( ( ( 42 - moonDay ) / 42 ) * 100 ) );
		var moonDirection = moonDay === 0 || moonDay === 42 ? 0 : ( moonDay < 42 ? 1 : 2 );
		var moonIndex = moonPhaseIndex( moonPercent, moonDirection );
		var weekdayIndex = Math.floor( ( minutes % MIN_PER_WEEK ) / MIN_PER_DAY );

		return {
			year: Math.floor( minutes / MIN_PER_YEAR ),
			month: Math.floor( ( minutes / MIN_PER_MONTH ) % 12 ) + 1,
			day: Math.floor( ( minutes / MIN_PER_DAY ) % 30 ) + 1,
			hour: Math.floor( ( minutes % MIN_PER_DAY ) / MIN_PER_HOUR ),
			minute: Math.floor( minutes % MIN_PER_HOUR ),
			weekdayIndex: weekdayIndex,
			weekday: WEEKDAYS[ weekdayIndex ],
			moonPercent: moonPercent,
			moonPhase: MOON_PHASES[ moonIndex ],
			moonIcon: MOON_ICONS[ moonIndex ]
		};
	}

	function pad2( n ) {
		return ( n < 10 ? '0' : '' ) + n;
	}

	/* ---------------------------------------------------------------- markup helpers */

	function el( tag, attrs, children ) {
		var node = document.createElement( tag );
		var key;
		for ( key in attrs || {} ) {
			if ( key === 'text' ) {
				node.textContent = attrs[ key ];
			} else {
				node.setAttribute( key, attrs[ key ] );
			}
		}
		( children || [] ).forEach( function ( child ) {
			node.appendChild( child );
		} );
		return node;
	}

	/** A Timeless portlet: heading plus body, ready to drop into a .sidebar-inner. */
	function portlet( id, label, body ) {
		return el( 'div', { role: 'navigation', 'class': 'mw-portlet', id: 'p-' + id,
			'aria-labelledby': 'p-' + id + '-label' }, [
			el( 'h3', { id: 'p-' + id + '-label', text: label } ),
			el( 'div', { 'class': 'mw-portlet-body' }, [ body ] )
		] );
	}

	/** A Timeless sidebar chunk: the collapsible outer container. */
	function chunk( id, heading, body ) {
		return el( 'div', { id: id, 'class': 'sidebar-chunk' }, [
			el( 'h2', {}, [ el( 'span', { text: heading } ) ] ),
			el( 'div', { 'class': 'sidebar-inner' }, [ body ] )
		] );
	}

	/* ---------------------------------------------------------------- Vana'diel panel */

	function buildClockPanel() {
		var clock = el( 'b', { 'class': 'pg-vana-clock' } );
		var date = el( 'span', { 'class': 'pg-vana-date' } );
		var day = el( 'span', { 'class': 'pg-vana-day' } );
		var moonIcon = el( 'span', { 'class': 'pg-vana-moon-icon' } );
		var moonPct = el( 'span', { 'class': 'pg-vana-moon-pct' } );
		var serverText = el( 'span', { 'class': 'pg-vana-server-text', text: 'Checking status' } );
		var serverCount = el( 'b', { 'class': 'pg-vana-server-count' } );
		var server = el( 'div', { 'class': 'pg-vana-line pg-vana-server', 'data-state': 'unknown' }, [
			el( 'span', { 'class': 'pg-vana-dot', 'aria-hidden': 'true' } ),
			serverText,
			serverCount
		] );

		var panel = el( 'div', { 'class': 'pg-vana' }, [
			el( 'div', { 'class': 'pg-vana-line' }, [
				clock, el( 'span', { 'class': 'pg-vana-tilde', text: '~' } ), date
			] ),
			el( 'div', { 'class': 'pg-vana-line' }, [
				day,
				el( 'span', { 'class': 'pg-vana-tilde', text: '~' } ),
				el( 'span', { 'class': 'pg-vana-moon' }, [ moonIcon, moonPct ] )
			] ),
			server
		] );

		var list = el( 'ul', {}, [
			el( 'li', { id: 'n-vanatime', 'class': 'mw-list-item' }, [ panel ] )
		] );

		// A Vana'diel minute is 2.4 Earth seconds, so a one-second tick looks smooth without
		// doing redundant work.
		function tick() {
			var v = vanadiel();
			clock.textContent = pad2( v.hour ) + ':' + pad2( v.minute );
			date.textContent = v.year + '-' + v.month + '-' + v.day;
			day.textContent = v.weekday;
			day.className = 'pg-vana-day pg-vana-day-' + v.weekdayIndex;
			moonIcon.textContent = v.moonIcon;
			moonIcon.setAttribute( 'title', v.moonPhase );
			moonPct.textContent = v.moonPercent + '%';
		}

		// Same origin as the wiki, so no CORS involved and no proxy needed.
		function poll() {
			fetch( '/api/status', { headers: { Accept: 'application/json' } } )
				.then( function ( res ) {
					if ( !res.ok ) {
						throw new Error( 'HTTP ' + res.status );
					}
					return res.json();
				} )
				.then( function ( data ) {
					var online = String( data && data.status || '' ).toLowerCase() === 'online';
					var players = Number( data && data.playerCount );
					server.setAttribute( 'data-state', online ? 'online' : 'offline' );
					serverText.textContent = online ? 'Online' : 'Offline';
					// A count of 0 is indistinguishable from "not published", and "Online 0" next
					// to a green dot reads as a bug, so only show a positive count.
					serverCount.textContent = online && players > 0 ? String( players ) : '';
				} )
				.catch( function () {
					// Keep whatever is on screen; the next poll may succeed.
					if ( server.getAttribute( 'data-state' ) === 'unknown' ) {
						serverText.textContent = 'Status unavailable';
					}
				} );
		}

		tick();
		poll();
		setInterval( tick, 1000 );
		setInterval( poll, 60000 );

		return portlet( 'vanatime', "Vana'diel Time", list );
	}

	/* ---------------------------------------------------------------- theme toggle
	 *
	 * Dark is the default and needs no class, so light mode is the opt-in state: MediaWiki:Timeless.css
	 * scopes every dark rule to html:not(.pg-day). That ordering means a reader on the default theme
	 * never sees a flash of light before this script runs.
	 */
	var THEME_KEY = 'pg-theme';

	function applyTheme( theme ) {
		document.documentElement.classList.toggle( 'pg-day', theme === 'day' );
	}

	function buildThemeToggle() {
		var link = el( 'a', { 'class': 'ext-darkmode-link', href: '#', role: 'button' } );
		var label = el( 'span', {} );
		link.appendChild( label );

		function refresh() {
			var isDay = document.documentElement.classList.contains( 'pg-day' );
			label.textContent = isDay ? 'Dark mode' : 'Light mode';
		}

		link.addEventListener( 'click', function ( e ) {
			e.preventDefault();
			var next = document.documentElement.classList.contains( 'pg-day' ) ? 'night' : 'day';
			applyTheme( next );
			try {
				localStorage.setItem( THEME_KEY, next );
			} catch ( err ) {
				// Private browsing or a full quota; the choice just will not persist.
			}
			refresh();
		} );

		refresh();

		return portlet( 'theme', 'Appearance', el( 'ul', {}, [
			el( 'li', { id: 't-darkmode', 'class': 'mw-list-item' }, [ link ] )
		] ) );
	}

	/* ---------------------------------------------------------------- Discord panel
	 *
	 * Discord's own embed (discord.com/widget) is a cross-origin iframe locked to Discord's blurple
	 * palette, so it cannot be themed to match this wiki. The member list is therefore fetched and
	 * rendered here instead: widget.json sends Access-Control-Allow-Origin for this host, so no proxy
	 * is involved and the markup is ours to style.
	 *
	 * That endpoint answers 403 whenever "Enable Server Widget" is switched off in Discord's server
	 * settings, which is why the static join panel is still kept as a fallback rather than deleted.
	 */
	var DISCORD_INVITE = 'https://discord.gg/M3NFvpm75E';
	var DISCORD_GUILD = '1473308284349059126';
	var DISCORD_WIDGET = 'https://discord.com/api/guilds/' + DISCORD_GUILD + '/widget.json';

	// Online first, then idle, then do-not-disturb, alphabetically within each.
	var STATUS_RANK = { online: 0, idle: 1, dnd: 2, offline: 3 };

	function statusRank( status ) {
		return STATUS_RANK[ status ] === undefined ? STATUS_RANK.offline : STATUS_RANK[ status ];
	}

	function discordMember( member ) {
		var status = STATUS_RANK[ member.status ] === undefined ? 'offline' : member.status;
		var children = [];

		if ( member.avatar_url ) {
			children.push( el( 'img', { 'class': 'pg-discord-avatar', src: member.avatar_url,
				alt: '', width: '20', height: '20', loading: 'lazy' } ) );
		} else {
			children.push( el( 'span', { 'class': 'pg-discord-avatar pg-discord-avatar-blank',
				'aria-hidden': 'true' } ) );
		}

		children.push( el( 'span', { 'class': 'pg-discord-member-name', text: member.username } ) );

		var li = el( 'li', { 'class': 'pg-discord-member', 'data-status': status }, children );
		// The status is conveyed by a colour swatch, so name it for anyone not seeing colour.
		li.setAttribute( 'title', member.game && member.game.name ?
			member.username + ' - ' + status + ' - playing ' + member.game.name :
			member.username + ' - ' + status );
		return li;
	}

	function buildDiscordPanel() {
		var count = el( 'span', { 'class': 'pg-discord-count', text: 'Loading' } );
		var list = el( 'ul', { 'class': 'pg-discord-members' } );

		var panel = el( 'div', { 'class': 'pg-discord' }, [
			el( 'div', { 'class': 'pg-discord-head' }, [
				el( 'span', { 'class': 'pg-discord-title', text: 'PegasusXI' } ),
				count
			] ),
			list,
			el( 'a', { 'class': 'pg-discord-join', href: DISCORD_INVITE,
				target: '_blank', rel: 'noopener', text: 'Join Discord' } )
		] );

		fetch( DISCORD_WIDGET, { headers: { Accept: 'application/json' } } )
			.then( function ( res ) {
				if ( !res.ok ) {
					throw new Error( 'HTTP ' + res.status );
				}
				return res.json();
			} )
			.then( function ( data ) {
				var members = ( ( data && data.members ) || [] ).slice();
				members.sort( function ( a, b ) {
					return statusRank( a.status ) - statusRank( b.status ) ||
						String( a.username ).localeCompare( String( b.username ) );
				} );

				// presence_count counts everyone online; the members array is capped by Discord.
				var online = Number( data && data.presence_count );
				if ( !isFinite( online ) ) {
					online = members.length;
				}
				count.textContent = online + ( online === 1 ? ' member online' : ' members online' );

				members.forEach( function ( member ) {
					list.appendChild( discordMember( member ) );
				} );

				panel.setAttribute( 'data-state', 'ready' );
			} )
			.catch( function () {
				// Most likely the server widget was switched off again. Fall back to the plain join
				// panel rather than leaving "Loading" on screen for good.
				panel.setAttribute( 'data-state', 'unavailable' );
				count.textContent = 'Chat, support and server announcements';
			} );

		return el( 'div', { id: 'discord-panel' }, [ panel ] );
	}

	/* ---------------------------------------------------------------- header button
	 *
	 * #mw-header is a flex row and Timeless assigns explicit orders: #p-logo-text 1, #p-search 2,
	 * #user-tools 3. A new child with no order defaults to 0 and jumps ahead of all of them, which is
	 * why this button first appeared at the far left of the header. MediaWiki:Timeless.css gives it
	 * order 4 so it sits at the right-hand end.
	 */

	function buildHeaderButton() {
		return el( 'div', { id: 'p-discord-signin' }, [
			el( 'a', { 'class': 'pg-discord-signin', href: DISCORD_INVITE,
				target: '_blank', rel: 'noopener' }, [
				el( 'span', { 'class': 'pg-discord-signin-mark', 'aria-hidden': 'true' } ),
				el( 'span', { text: 'Join Discord' } )
			] )
		] );
	}

	/* ---------------------------------------------------------------- wire it up */

	function init() {
		// Restore the saved theme first so the toggle's label starts out correct.
		try {
			if ( localStorage.getItem( THEME_KEY ) === 'day' ) {
				applyTheme( 'day' );
			}
		} catch ( err ) {}

		var siteNav = document.getElementById( 'mw-site-navigation' );
		var navChunk = document.getElementById( 'site-navigation' );

		if ( navChunk ) {
			var inner = navChunk.querySelector( '.sidebar-inner' );
			if ( inner ) {
				inner.insertBefore( buildClockPanel(), inner.firstChild );
			}
		}

		if ( siteNav && !document.getElementById( 'theme-tools' ) ) {
			siteNav.appendChild( chunk( 'theme-tools', 'Appearance', buildThemeToggle() ) );
		}

		var related = document.getElementById( 'mw-related-navigation' );
		if ( related && !document.getElementById( 'discord-panel' ) ) {
			related.insertBefore( buildDiscordPanel(), related.firstChild );
		}

		// #user-tools is inline-block, so the button has to sit beside it rather than before the
		// whole header block, or the personal menu wraps onto its own line.
		var userTools = document.getElementById( 'user-tools' );
		if ( userTools && userTools.parentNode && !document.getElementById( 'p-discord-signin' ) ) {
			userTools.parentNode.insertBefore( buildHeaderButton(), userTools );
		}
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', init );
	} else {
		init();
	}
}() );