MediaWiki:Gadget-pegasus.js: Difference between revisions
From PegasusXI Wiki
PegasusAdmin (talk | contribs) Header button starts Discord sign-in instead of linking to the invite |
PegasusAdmin (talk | contribs) Header button starts Discord sign-in instead of linking to the invite |
||
| Line 349: | Line 349: | ||
* This starts a sign-in rather than handing out an invite: the Discord invite is already on the | * This starts a sign-in rather than handing out an invite: the Discord invite is already on the | ||
* sidebar panel, and a reader who wants an account is better served by the thing that makes one. | * sidebar panel, and a reader who wants an account is better served by the thing that makes one. | ||
*/ | */ | ||
/* The flow cannot be started by a link. It begins with a POST of MediaWiki's login form carrying | |||
* a login token, and Special:PluggableAuthLogin on its own has no plugin recorded in the session, | |||
* so it just errors. Rather than leave the reader on the login page to press a second button, the | |||
* header link carries this marker and the gadget presses the real button on arrival. The form and | |||
* token are MediaWiki's own, so nothing is forged, and with JavaScript off the reader simply sees | |||
* the login page with the Discord button waiting. | |||
*/ | |||
var AUTOSTART_PARAM = 'pgdiscord'; | |||
function buildHeaderButton() { | function buildHeaderButton() { | ||
| Line 361: | Line 367: | ||
var articlePath = mw.config.get( 'wgArticlePath' ) || '/wiki/$1'; | var articlePath = mw.config.get( 'wgArticlePath' ) || '/wiki/$1'; | ||
var href = articlePath.replace( '$1', 'Special:UserLogin' ) + | var params = new URLSearchParams(); | ||
params.set( 'returnto', mw.config.get( 'wgPageName' ) || 'Main_Page' ); | |||
params.set( AUTOSTART_PARAM, '1' ); | |||
var href = articlePath.replace( '$1', 'Special:UserLogin' ) + '?' + params.toString(); | |||
return el( 'div', { id: 'p-discord-signin' }, [ | return el( 'div', { id: 'p-discord-signin' }, [ | ||
| Line 370: | Line 378: | ||
] ) | ] ) | ||
] ); | ] ); | ||
} | |||
/** On the login page, press the Discord button if we were sent there to do exactly that. */ | |||
function maybeAutostart() { | |||
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'Userlogin' ) { | |||
return; | |||
} | |||
var query = new URLSearchParams( location.search ); | |||
if ( query.get( AUTOSTART_PARAM ) !== '1' ) { | |||
return; | |||
} | |||
// A refused or failed attempt comes back here with a message. Pressing the button again | |||
// would bounce the reader straight back out and hide the reason from them. | |||
if ( query.has( 'error' ) || query.has( 'warning' ) ) { | |||
return; | |||
} | |||
if ( document.querySelector( | |||
'.cdx-message--error, .errorbox, .mw-message-box-error, .error' | |||
) ) { | |||
return; | |||
} | |||
// Matched by name prefix rather than index: the suffix is the plugin's position in | |||
// $wgPluggableAuth_Config, which would shift if a second provider were ever added. | |||
var button = document.querySelector( | |||
'button[name^="pluggableauthlogin"], input[name^="pluggableauthlogin"]' | |||
); | |||
if ( button ) { | |||
button.click(); | |||
} | |||
} | } | ||
| Line 381: | Line 421: | ||
} | } | ||
} catch ( err ) {} | } catch ( err ) {} | ||
// Before building anything else: if this is a hand-off to Discord the page is about to be | |||
// replaced, so there is no point populating a sidebar nobody will see. | |||
maybeAutostart(); | |||
var siteNav = document.getElementById( 'mw-site-navigation' ); | var siteNav = document.getElementById( 'mw-site-navigation' ); | ||
Revision as of 00:33, 30 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.
*
* This starts a sign-in rather than handing out an invite: the Discord invite is already on the
* sidebar panel, and a reader who wants an account is better served by the thing that makes one.
*/
/* The flow cannot be started by a link. It begins with a POST of MediaWiki's login form carrying
* a login token, and Special:PluggableAuthLogin on its own has no plugin recorded in the session,
* so it just errors. Rather than leave the reader on the login page to press a second button, the
* header link carries this marker and the gadget presses the real button on arrival. The form and
* token are MediaWiki's own, so nothing is forged, and with JavaScript off the reader simply sees
* the login page with the Discord button waiting.
*/
var AUTOSTART_PARAM = 'pgdiscord';
function buildHeaderButton() {
// Logged-in readers have the personal menu instead; a sign-in button would be noise.
if ( mw.config.get( 'wgUserName' ) ) {
return null;
}
var articlePath = mw.config.get( 'wgArticlePath' ) || '/wiki/$1';
var params = new URLSearchParams();
params.set( 'returnto', mw.config.get( 'wgPageName' ) || 'Main_Page' );
params.set( AUTOSTART_PARAM, '1' );
var href = articlePath.replace( '$1', 'Special:UserLogin' ) + '?' + params.toString();
return el( 'div', { id: 'p-discord-signin' }, [
el( 'a', { 'class': 'pg-discord-signin', href: href }, [
el( 'span', { 'class': 'pg-discord-signin-mark', 'aria-hidden': 'true' } ),
el( 'span', { text: 'Sign in with Discord' } )
] )
] );
}
/** On the login page, press the Discord button if we were sent there to do exactly that. */
function maybeAutostart() {
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'Userlogin' ) {
return;
}
var query = new URLSearchParams( location.search );
if ( query.get( AUTOSTART_PARAM ) !== '1' ) {
return;
}
// A refused or failed attempt comes back here with a message. Pressing the button again
// would bounce the reader straight back out and hide the reason from them.
if ( query.has( 'error' ) || query.has( 'warning' ) ) {
return;
}
if ( document.querySelector(
'.cdx-message--error, .errorbox, .mw-message-box-error, .error'
) ) {
return;
}
// Matched by name prefix rather than index: the suffix is the plugin's position in
// $wgPluggableAuth_Config, which would shift if a second provider were ever added.
var button = document.querySelector(
'button[name^="pluggableauthlogin"], input[name^="pluggableauthlogin"]'
);
if ( button ) {
button.click();
}
}
/* ---------------------------------------------------------------- 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 ) {}
// Before building anything else: if this is a hand-off to Discord the page is about to be
// replaced, so there is no point populating a sidebar nobody will see.
maybeAutostart();
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 );
}
var userTools = document.getElementById( 'user-tools' );
if ( userTools && userTools.parentNode && !document.getElementById( 'p-discord-signin' ) ) {
var button = buildHeaderButton();
if ( button ) {
userTools.parentNode.insertBefore( button, userTools );
}
}
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
}() );
