MediaWiki:Gadget-pegasus.js: Difference between revisions
From PegasusXI Wiki
PegasusAdmin (talk | contribs) Vana'diel clock, theme toggle and Discord panel |
PegasusAdmin (talk | contribs) Render the live Discord member list; fix header button placement |
||
| Line 248: | Line 248: | ||
/* ---------------------------------------------------------------- Discord panel | /* ---------------------------------------------------------------- Discord panel | ||
* | * | ||
* The | * Discord's own embed (discord.com/widget) is a cross-origin iframe locked to Discord's blurple | ||
* settings | * 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_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() { | ||
var count = el( 'span', { 'class': 'pg-discord-count', text: 'Loading' } ); | |||
var list = el( 'ul', { 'class': 'pg-discord-members' } ); | |||
el( ' | var panel = el( 'div', { 'class': 'pg-discord' }, [ | ||
el( 'div', { 'class': 'pg-discord-head' }, [ | |||
el( 'a', { 'class': 'pg-discord-join', href: DISCORD_INVITE, | 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 */ | /* ---------------------------------------------------------------- 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();
}
}() );
