MediaWiki:Common.portal.js: Difference between revisions
MediaWiki interface page
More actions
Created page with "→Portal image slider — MediaWiki:Common.js * * Wires up any .portal-slider-wrapper on the page. * The Lua module (Module:Portal|randomImage) renders the first image * server-side; the JS fetches the full ImageList sub-page via the API * and pre-loads remaining images for the slider. * * Falls back gracefully: if JS is disabled, the first random image * is still shown (rendered server-side by Lua).: ( function ( mw, $ ) { 'use strict'; /* ── Co..." |
No edit summary |
||
| (12 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* Portal | /** | ||
* BattlestarWiki — Portal page JavaScript | |||
* MediaWiki:Common.portal.js (or append to MediaWiki:Common.js) | |||
* | * | ||
* | * Handles per-portal widgets on any .portal-page: | ||
* | * 1. Image carousel | ||
* | * 2. Newest article (portal-scoped) | ||
* | * 3. Stats bar (live article count) | ||
* | * | ||
* | * Self-contained — does not depend on helpers from the main page JS. | ||
*/ | */ | ||
( function ( | |||
( function () { | |||
'use strict'; | 'use strict'; | ||
/* ── | /* ── API endpoints ──────────────────────────────────────────────── */ | ||
var | |||
var | var API = 'https://en.battlestarwiki.org/w/api.php'; | ||
var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php'; | |||
/* ── Shared helpers ─────────────────────────────────────────────── */ | |||
function dailySeed() { | |||
var now = new Date(); | |||
return Math.floor( Date.UTC( | |||
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() | |||
) / 86400000 ); | |||
} | |||
function esc( s ) { | |||
return String( s || '' ) | |||
.replace( /&/g, '&' ).replace( /</g, '<' ) | |||
.replace( />/g, '>' ).replace( /"/g, '"' ); | |||
} | |||
function apiGetFrom( baseUrl, params, crossOrigin ) { | |||
params.format = 'json'; | |||
if ( crossOrigin ) { params.origin = '*'; } | |||
var qs = Object.keys( params ) | |||
.map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } ) | |||
.join( '&' ); | |||
return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } ) | |||
.then( function ( r ) { | |||
if ( !r.ok ) throw new Error( 'HTTP ' + r.status ); | |||
return r.json(); | |||
} ); | |||
} | |||
/* Same-origin: use relative URL, no origin param needed */ | |||
function apiGet( params ) { | |||
return apiGetFrom( '/w/api.php', params, false ); | |||
} | |||
/* Cross-origin: use absolute URL + origin=* for CORS */ | |||
function apiGetCross( params ) { | |||
return apiGetFrom( MEDIA_API, params, true ); | |||
} | |||
/* ── Portal entry point ───────────────────────────────────────── */ | |||
mw.hook( 'wikipage.content' ).add( function () { | |||
var page = document.querySelector( '.portal-page' ); | |||
if ( !page ) return; | |||
initPortalSlider( page ); | |||
initPortalNewest( page ); | |||
initPortalStats( page ); | |||
initPortalOrphans( page ); | |||
initPortalFeatured( page ); | |||
} ); | |||
/* ════════════════════════════════════════════════════════════════ | |||
1. IMAGE CAROUSEL | |||
Reads Portal:NAME/ImageList for a curated file list, falls back | |||
to querying the category directly. Images are fetched via the | |||
media wiki's imageinfo API for thumbnail URLs. | |||
════════════════════════════════════════════════════════════════ */ | |||
/* | function initPortalSlider( page ) { | ||
var hero = page.querySelector( '.portal-hero' ); | |||
if ( !hero ) return; | |||
var category = hero.dataset.category || ''; | |||
if ( !category ) return; | |||
/* Random seed per page load — different slides every visit */ | |||
var seed = Math.floor( Math.random() * 2147483647 ) + 1; | |||
/* Query articles from the portal category on en.battlestarwiki.org. | |||
Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */ | |||
apiGet( { | |||
action: 'query', | |||
list: 'categorymembers', | |||
cmtitle: 'Category:' + category, | |||
cmtype: 'page', | |||
cmnamespace: '0', | |||
cmlimit: '200' | |||
} ).then( function ( data ) { | |||
var members = ( data.query.categorymembers || [] ); | |||
if ( !members.length ) throw new Error( 'empty category' ); | |||
/* Daily-seeded shuffle, cap at 20 candidates to fetch images for */ | |||
members = seededShuffle( members, seed ).slice( 0, 20 ); | |||
var titles = members.map( function ( m ) { return m.title; } ).join( '|' ); | |||
/* Fetch pageimage + extract + categories for each article */ | |||
return apiGet( { | |||
action: 'query', | |||
titles: titles, | |||
prop: 'pageimages|extracts|categories', | |||
piprop: 'thumbnail', | |||
pithumbsize: '1200', | |||
pilimit: '20', | |||
exintro: '1', | |||
exchars: '200', | |||
exsectionformat: 'plain', | |||
cllimit: '20', | |||
formatversion: '2' | |||
} ).then( function ( d ) { | |||
var slides = []; | |||
( d.query.pages || [] ).forEach( function ( p ) { | |||
/* Skip articles with no image */ | |||
if ( !p.thumbnail || !p.thumbnail.source ) return; | |||
var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim(); | |||
if ( extract.length > 180 ) { | |||
extract = extract.replace( /\s\S+$/, '' ) + '…'; | |||
} | |||
/* Derive badge label from article categories */ | |||
var badge = portalSlideBadge( p.categories || [] ); | |||
slides.push( { | |||
thumb: p.thumbnail.source, | |||
caption: extract || p.title, | |||
title: p.title, | |||
link: p.title, | |||
badge: badge | |||
} ); | |||
} ); | |||
return slides; | |||
} ); | |||
} ).then( function ( slides ) { | |||
if ( !slides.length ) throw new Error( 'No slides with images' ); | |||
buildCarousel( hero, slides ); | |||
} ).catch( function () { /* silent — hero stays dark */ } ); | |||
} | |||
/** Map article categories to a human-readable slide badge label. | |||
* Priority order matters — more specific checks first. */ | |||
function portalSlideBadge( cats ) { | |||
var names = cats.map( function ( c ) { | |||
return c.title.replace( /^Category:/, '' ).toLowerCase(); | |||
} ); | } ); | ||
/* Exact/prefix matches in priority order */ | |||
var checks = [ | |||
/* Episodes first — most specific */ | |||
[ 'episode guide', 'Episode' ], | |||
[ 'episodes by', 'Episode' ], | |||
[ 'miniseries', 'Episode' ], | |||
/* Ships / Craft */ | |||
[ 'colonial craft', 'Ship' ], | |||
[ 'cylon craft', 'Ship' ], | |||
[ 'ships (', 'Ship' ], | |||
[ 'battlestars (', 'Ship' ], | |||
[ 'vipers (', 'Ship' ], | |||
[ 'raptors', 'Ship' ], | |||
/* Characters */ | |||
[ 'main characters', 'Character' ], | |||
[ 'recurring guest', 'Character' ], | |||
[ 'one-shot characters', 'Character' ], | |||
[ 'deceased characters', 'Character' ], | |||
[ 'mentioned-only characters', 'Character' ], | |||
[ 'characters (', 'Character' ], | |||
/* Cylons (before military) */ | |||
[ 'cylons (', 'Cylon' ], | |||
[ 'cylon military', 'Cylon' ], | |||
[ 'cylon religion', 'Cylon' ], | |||
/* Military */ | |||
[ 'colonial military', 'Military' ], | |||
[ 'colonial warriors', 'Military' ], | |||
[ 'gaeta mutineers', 'Military' ], | |||
[ 'fighter squadrons', 'Military' ], | |||
/* Places / Locations */ | |||
[ 'planets (', 'Planet' ], | |||
[ 'moons (', 'Moon' ], | |||
[ 'places (', 'Location' ], | |||
[ 'galactica areas', 'Location' ], | |||
[ 'places on ', 'Location' ], | |||
[ 'basestar areas', 'Location' ], | |||
[ 'twelve colonies', 'Location' ], | |||
/* Society / Culture */ | |||
[ 'colonial religion', 'Religion' ], | |||
[ 'colonial society', 'Society' ], | |||
[ 'colonial government', 'Government' ], | |||
[ 'colonial history', 'History' ], | |||
[ 'organizations (', 'Organization' ], | |||
[ 'ha'la'tha', 'Organization' ], | |||
[ 'soldiers of the one', 'Organization' ], | |||
/* Technology */ | |||
[ 'technology (', 'Technology' ], | |||
[ 'weapons (', 'Weapon' ], | |||
[ 'drugs (', 'Technology' ], | |||
/* Production / Real world */ | |||
[ 'cast (', 'Cast' ], | |||
[ 'crew (', 'Crew' ], | |||
[ 'directors (', 'Director' ], | |||
[ 'writers (', 'Writer' ], | |||
[ 'producers (', 'Producer' ], | |||
/* Comics / Books */ | |||
[ 'dynamite comics', 'Comic' ], | |||
[ 'comics (', 'Comic' ], | |||
[ 'novels', 'Novel' ], | |||
[ 'books (', 'Book' ], | |||
/* Terminology */ | |||
[ 'terminology (', 'Term' ], | |||
[ 'descriptive terms', 'Term' ] | |||
]; | |||
for ( var i = 0; i < checks.length; i++ ) { | |||
for ( var j = 0; j < names.length; j++ ) { | |||
if ( names[j].indexOf( checks[i][0] ) !== -1 ) { | |||
return checks[i][1]; | |||
} | |||
} | |||
} | |||
return ''; /* empty = fall back to seriesBadge in caller */ | |||
} | |||
/** Build and wire the carousel DOM — bsw-hero style */ | |||
function buildCarousel( hero, slides ) { | |||
var current = 0; | var current = 0; | ||
var | var total = slides.length; | ||
var | var timer; | ||
/* Derive portal series label from data-category for the slide badge */ | |||
var cat = ( hero.dataset.category || '' ).toUpperCase(); | |||
var seriesBadge = cat || 'BSG'; | |||
var bg = hero.querySelector( '.portal-hero-bg' ); | |||
var caption = hero.querySelector( '.portal-hero-caption' ); | |||
var dotsEl = hero.querySelector( '.portal-hero-dots' ); | |||
var prevBtn = hero.querySelector( '.portal-hero-prev' ); | |||
var nextBtn = hero.querySelector( '.portal-hero-next' ); | |||
/* Clickable overlay covering the image area — sits above bg/overlay, | |||
below nav and footer so those remain independently clickable */ | |||
var link = hero.querySelector( '.portal-hero-link' ); | |||
if ( !link ) { | |||
link = document.createElement( 'a' ); | |||
link.className = 'portal-hero-link'; | |||
/* Insert after overlay, before content */ | |||
var content = hero.querySelector( '.portal-hero-content' ); | |||
hero.insertBefore( link, content ); | |||
} | |||
/* | /* Build dot indicators */ | ||
if ( | if ( dotsEl ) { | ||
dotsEl.innerHTML = ''; | |||
slides.forEach( function ( _, i ) { | |||
. | var dot = document.createElement( 'span' ); | ||
. | dot.className = 'portal-hero-dot' + ( i === 0 ? ' is-active' : '' ); | ||
. | dot.setAttribute( 'role', 'button' ); | ||
dot.setAttribute( 'tabindex', '0' ); | |||
dot.addEventListener( 'click', function () { goTo( i ); } ); | |||
dotsEl.appendChild( dot ); | |||
} ); | |||
} | |||
function goTo( n ) { | |||
var | current = ( n + total ) % total; | ||
var s = slides[ current ]; | |||
// | /* Set background image on the bg div — same as bsw-slide-bg */ | ||
if ( bg ) { | |||
} | bg.style.backgroundImage = 'url(' + s.thumb + ')'; | ||
} | |||
if ( caption ) { | |||
caption.innerHTML = | |||
( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) + | |||
'<div class="portal-hero-title">' + esc( s.title ) + '</div>' + | |||
( s.caption && s.caption !== s.title | |||
? '<div class="portal-hero-extract">' + esc( s.caption ) + '</div>' | |||
: '' ); | |||
} | |||
/* Update the clickable overlay link */ | |||
if ( link ) { | |||
if ( s.link ) { | |||
link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) ); | |||
link.title = s.link; | |||
link.style.display = 'block'; | |||
} else { | |||
/* No article link for this slide — disable the overlay */ | |||
link.removeAttribute( 'href' ); | |||
link.style.display = 'none'; | |||
} | |||
} | |||
if ( dotsEl ) { | |||
dotsEl.querySelectorAll( '.portal-hero-dot' ).forEach( function ( d, i ) { | |||
d.classList.toggle( 'is-active', i === current ); | |||
} ); | |||
} | |||
resetTimer(); | |||
} | |||
function resetTimer() { | |||
clearInterval( timer ); | |||
timer = setInterval( function () { goTo( current + 1 ); }, 6000 ); | |||
} | |||
if ( prevBtn ) { | |||
prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } ); | |||
prevBtn.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); } | |||
} ); | |||
} | |||
if ( nextBtn ) { | |||
nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } ); | |||
nextBtn.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); } | |||
} ); | |||
} | } | ||
/* | hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } ); | ||
hero.addEventListener( 'mouseleave', resetTimer ); | |||
/* Touch/swipe support */ | |||
var touchX = null; | |||
hero.addEventListener( 'touchstart', function ( e ) { | |||
touchX = e.touches[0].clientX; | |||
}, { passive: true } ); | |||
hero.addEventListener( 'touchend', function ( e ) { | |||
if ( touchX === null ) return; | |||
var dx = e.changedTouches[0].clientX - touchX; | |||
if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 ); | |||
touchX = null; | |||
}, { passive: true } ); | |||
goTo( 0 ); | |||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
2. NEWEST ARTICLE (portal-scoped) | |||
Same logevents call as the main page newest widget, but filtered | |||
to the portal's category via a follow-up categorymembers check. | |||
════════════════════════════════════════════════════════════════ */ | |||
function initPortalNewest( page ) { | |||
var inner = page.querySelector( '.portal-newest-inner' ); | |||
if ( !inner ) return; | |||
var category = inner.dataset.category || ''; | |||
if ( !category ) return; | if ( !category ) return; | ||
var | /* Fetch the 30 most recently created mainspace pages */ | ||
apiGet( { | |||
action: 'query', | |||
list: 'logevents', | |||
letype: 'create', | |||
lenamespace: '0', | |||
lelimit: '30', | |||
leprop: 'title|timestamp' | |||
} ).then( function ( data ) { | |||
var entries = ( data.query.logevents || [] ).filter( function ( e ) { | |||
return e.title.indexOf( '/' ) === -1 && e.title !== 'Main Page'; | |||
} ); | |||
if ( !entries.length ) throw new Error( 'No entries' ); | |||
/* Check which of these titles are in the portal's category */ | |||
var titles = entries.map( function ( e ) { return e.title; } ); | |||
/* Build a timestamp map for quick lookup */ | |||
var tsMap = {}; | |||
entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } ); | |||
/* Check multiple RDM categories so articles in subcats are matched */ | |||
var catCandidates = [ | |||
'Category:' + category, | |||
'Category:Characters (RDM)', | |||
'Category:Characters (TRS)', | |||
'Category:Episode Guide (RDM)', | |||
'Category:Colonial Military (RDM)', | |||
'Category:Colonial Craft (RDM)', | |||
'Category:Ships (RDM)', | |||
'Category:Places (RDM)', | |||
'Category:Technology (RDM)', | |||
'Category:Terminology (RDM)', | |||
'Category:Colonial Society (RDM)', | |||
'Category:Colonial History (RDM)', | |||
'Category:Cylons (RDM)' | |||
]; | |||
return apiGet( { | |||
action: 'query', | |||
// | titles: titles.slice( 0, 20 ).join( '|' ), | ||
prop: 'categories', | |||
return; | clcategories: catCandidates.join( '|' ), | ||
cllimit: '20' | |||
} ).then( function ( d ) { | |||
/* Find pages that ARE in any of the candidate categories */ | |||
var pages = Object.values( d.query.pages || {} ); | |||
var match = pages.find( function ( p ) { | |||
return p.categories && p.categories.length > 0; | |||
} ); | |||
/* If none match in the first 20, fall back to the single most recent */ | |||
var title = match ? match.title : entries[0].title; | |||
var ts = tsMap[ title ]; | |||
return apiGet( { | |||
action: 'query', | |||
titles: title, | |||
prop: 'extracts|pageimages|pageprops', | |||
exintro: '1', | |||
exchars: '400', | |||
exsectionformat: 'plain', | |||
piprop: 'thumbnail', | |||
pithumbsize: '200', | |||
} | ppprop: 'disambiguation' | ||
} ).then( function ( d2 ) { | |||
var p = Object.values( d2.query.pages || {} )[0] || {}; | |||
/* Skip disambiguation, try plain most-recent as fallback */ | |||
if ( p.pageprops && p.pageprops.disambiguation !== undefined ) { | |||
title = entries[0].title; | |||
ts = entries[0].timestamp; | |||
return apiGet( { | |||
action: 'query', titles: title, | |||
prop: 'extracts|pageimages', exintro: '1', exchars: '400', | |||
exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200' | |||
} ).then( function ( d3 ) { | |||
return { page: Object.values( d3.query.pages || {} )[0] || {}, ts: ts }; | |||
} ); | |||
} | |||
return { page: p, ts: ts }; | |||
} ); | |||
} ); | } ); | ||
} ).then( function ( result ) { | |||
renderPortalNewest( inner, result.page, result.ts ); | |||
} ).catch( function () { | |||
inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-subtle,#888)">Could not load newest article.</div>'; | |||
} ); | |||
} | |||
function renderPortalNewest( inner, page, ts ) { | |||
var title = page.title || ''; | |||
var url = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) ); | |||
var thumb = page.thumbnail ? page.thumbnail.source : ''; | |||
var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 ); | |||
if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026'; | |||
var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : ''; | |||
var thumbHtml = thumb | |||
? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" style="width:70px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">' | |||
: ''; | |||
inner.innerHTML = | |||
'<div style="display:flex;gap:10px;align-items:flex-start">' + | |||
thumbHtml + | |||
'<div>' + | |||
'<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' + | |||
( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) + | |||
'<div style="font-size:0.8em;color:var(--color-base--subtle,#666);margin-top:3px;line-height:1.5">' + esc( extract ) + '</div>' + | |||
'<div style="margin-top:4px"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link,#3366cc)">Read more \u2192</a></div>' + | |||
'</div>' + | |||
'</div>'; | |||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
3. STATS BAR — live article count | |||
The stat_count span with data-category gets its number filled | |||
in from categoryinfo. All other stats are static wikitext. | |||
════════════════════════════════════════════════════════════════ */ | |||
function initPortalStats( page ) { | |||
var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' ); | |||
if ( !countEls.length ) return; | |||
/* Batch all stat categories into a single categoryinfo query */ | |||
var titles = countEls.length | |||
? Array.prototype.slice.call( countEls ).map( function ( el ) { | |||
return 'Category:' + el.dataset.category; | |||
} ) | |||
: []; | |||
apiGet( { | |||
action: 'query', | |||
titles: titles.join( '|' ), | |||
prop: 'categoryinfo', | |||
formatversion: '2' | |||
} ).then( function ( data ) { | |||
var pageMap = {}; | |||
( data.query.pages || [] ).forEach( function ( p ) { | |||
pageMap[ p.title ] = p; | |||
} ); | |||
countEls.forEach( function ( el ) { | |||
var key = 'Category:' + el.dataset.category; | |||
var p = pageMap[ key ]; | |||
if ( p && p.categoryinfo ) { | |||
el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString(); | |||
} else { | |||
el.textContent = '\u2014'; | |||
} | |||
} ); | |||
} ).catch( function () { | } ).catch( function () { | ||
countEls.forEach( function ( el ) { el.textContent = '\u2014'; } ); | |||
} ); | } ); | ||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
4. ORPHANED ARTICLES — dynamic, filtered to portal category | |||
Uses the MW API to find articles with no incoming links that | |||
are also members of the portal's stat_category. | |||
════════════════════════════════════════════════════════════════ */ | |||
function initPortalOrphans( page ) { | |||
var widget = page.querySelector( '#portal-widget-orphans[data-category]' ); | |||
if ( !widget ) return; | |||
var inner = widget.querySelector( '.portal-orphans-inner' ); | |||
var category = widget.dataset.category; | |||
if ( !inner || !category ) return; | |||
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>'; | |||
/* | /* Fetch global lonely pages, then filter to those in this portal's category. | ||
list=querypage is available to all users; qplimit caps at 10 for non-sysops | |||
but we fetch 500 and let the server cap it — more matches for the category. */ | |||
apiGet( { | |||
action: 'query', | |||
list: 'querypage', | |||
qppage: 'Lonelypages', | |||
qplimit: '500' | |||
} ).then( function ( data ) { | |||
var lonely = ( ( data.query.querypage || {} ).results || [] ) | |||
.map( function ( r ) { return r.title; } ); | |||
if ( !lonely.length ) return []; | |||
/* Cross-reference against the portal's category in batches of 50 */ | |||
var batch = lonely.slice( 0, 50 ).join( '|' ); | |||
if ( | return apiGet( { | ||
action: 'query', | |||
if ( | titles: batch, | ||
prop: 'categories', | |||
clcategories: 'Category:' + category, | |||
cllimit: '1', | |||
formatversion: '2' | |||
} ).then( function ( d ) { | |||
var orphans = []; | |||
( d.query.pages || [] ).forEach( function ( p ) { | |||
if ( p.missing ) return; | |||
/* categories present = this page IS in the portal category */ | |||
if ( p.categories && p.categories.length > 0 ) { | |||
orphans.push( p.title ); | |||
} | } | ||
} ); | } ); | ||
return orphans; | |||
} ); | |||
} ).then( function ( orphans ) { | |||
if ( !orphans.length ) { | |||
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>'; | |||
return; | |||
} | |||
inner.innerHTML = orphans.map( function ( t ) { | |||
var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) ); | |||
return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>'; | |||
} ).join( '' ); | |||
} ).catch( function () { | |||
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>'; | |||
} ); | |||
} | |||
/* ════════════════════════════════════════════════════════════════ | |||
5. FEATURED EPISODE SLIDER | |||
Reads a pipe-separated list of episode titles from the widget's | |||
data-episodes attribute (one per season), fetches extract + | |||
thumbnail for each, and builds a carousel. | |||
════════════════════════════════════════════════════════════════ */ | |||
function initPortalFeatured( page ) { | |||
var widget = page.querySelector( '#portal-widget-featured[data-episodes]' ); | |||
if ( !widget ) return; | |||
var body = widget.querySelector( '.portal-featured-body' ); | |||
var dotsEl = widget.querySelector( '.portal-featured-dots' ); | |||
var prevBtn = widget.querySelector( '.portal-featured-prev' ); | |||
var nextBtn = widget.querySelector( '.portal-featured-next' ); | |||
var episodes = ( widget.dataset.episodes || '' ).split( '|' ).map( function ( s ) { return s.trim(); } ).filter( Boolean ); | |||
if ( !episodes.length || !body ) return; | |||
body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>'; | |||
apiGet( { | |||
action: 'query', | |||
titles: episodes.join( '|' ), | |||
prop: 'extracts|pageimages', | |||
exintro: '1', | |||
exchars: '500', | |||
exsectionformat: 'plain', | |||
piprop: 'thumbnail', | |||
pithumbsize: '400', | |||
formatversion: '2' | |||
} ).then( function ( data ) { | |||
var pageMap = {}; | |||
( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } ); | |||
/* Preserve the requested episode order */ | |||
var slides = episodes.map( function ( t ) { | |||
var p = pageMap[ t ] || {}; | |||
return { | |||
title: t, | |||
extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ), | |||
thumb: p.thumbnail ? p.thumbnail.source : '', | |||
url: '/' + encodeURIComponent( t.replace( / /g, '_' ) ) | |||
}; | |||
} ); | } ); | ||
buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ); | |||
} ).catch( function () { | |||
( | body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>'; | ||
} ); | |||
} | |||
function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) { | |||
var current = 0; | |||
var total = slides.length; | |||
function render( n ) { | |||
function | current = ( n + total ) % total; | ||
current = ( | var s = slides[ current ]; | ||
var | |||
var thumbHtml = s.thumb | |||
? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">' | |||
: ''; | |||
var extract = s.extract; | |||
if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…'; | |||
body.innerHTML = | |||
'<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' + | |||
'<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' + | |||
'<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' + | |||
'<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>'; | |||
if ( dotsEl ) { | |||
dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) { | |||
d.classList.toggle( 'is-active', i === current ); | |||
} ); | |||
} | |||
} | } | ||
/* | /* Build dots */ | ||
if ( dotsEl ) { | |||
dotsEl.innerHTML = ''; | |||
slides.forEach( function ( _, i ) { | |||
var dot = document.createElement( 'span' ); | |||
dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' ); | |||
dot.setAttribute( 'role', 'button' ); | |||
dot.setAttribute( 'tabindex', '0' ); | |||
dot.title = 'Season ' + ( i + 1 ); | |||
} | dot.addEventListener( 'click', function () { render( i ); } ); | ||
dotsEl.appendChild( dot ); | |||
} ); | |||
} | } | ||
if ( prevBtn ) { | |||
prevBtn.addEventListener( 'click', function () { render( current - 1 ); } ); | |||
prevBtn.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); } | |||
} ); | |||
} | } | ||
function | if ( nextBtn ) { | ||
if ( | nextBtn.addEventListener( 'click', function () { render( current + 1 ); } ); | ||
nextBtn.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); } | |||
} ); | |||
} | } | ||
render( 0 ); | |||
} | } | ||
/* ── Seeded shuffle (stable within a calendar day) ────────────── */ | |||
function seededShuffle( arr | function seededShuffle( arr, seed ) { | ||
var a = arr.slice(); | var a = arr.slice(); | ||
var s = seed; | |||
for ( var i = a.length - 1; i > 0; i-- ) { | for ( var i = a.length - 1; i > 0; i-- ) { | ||
var j = | /* Xorshift32 */ | ||
s ^= s << 13; | |||
s ^= s >> 17; | |||
s ^= s << 5; | |||
var j = ( ( s >>> 0 ) % ( i + 1 ) ); | |||
var t = a[i]; a[i] = a[j]; a[j] = t; | var t = a[i]; a[i] = a[j]; a[j] = t; | ||
} | } | ||
| Line 248: | Line 704: | ||
} | } | ||
}( | }() ); | ||
Latest revision as of 02:55, 14 April 2026
/**
* BattlestarWiki — Portal page JavaScript
* MediaWiki:Common.portal.js (or append to MediaWiki:Common.js)
*
* Handles per-portal widgets on any .portal-page:
* 1. Image carousel
* 2. Newest article (portal-scoped)
* 3. Stats bar (live article count)
*
* Self-contained — does not depend on helpers from the main page JS.
*/
( function () {
'use strict';
/* ── API endpoints ──────────────────────────────────────────────── */
var API = 'https://en.battlestarwiki.org/w/api.php';
var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';
/* ── Shared helpers ─────────────────────────────────────────────── */
function dailySeed() {
var now = new Date();
return Math.floor( Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
) / 86400000 );
}
function esc( s ) {
return String( s || '' )
.replace( /&/g, '&' ).replace( /</g, '<' )
.replace( />/g, '>' ).replace( /"/g, '"' );
}
function apiGetFrom( baseUrl, params, crossOrigin ) {
params.format = 'json';
if ( crossOrigin ) { params.origin = '*'; }
var qs = Object.keys( params )
.map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } )
.join( '&' );
return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } )
.then( function ( r ) {
if ( !r.ok ) throw new Error( 'HTTP ' + r.status );
return r.json();
} );
}
/* Same-origin: use relative URL, no origin param needed */
function apiGet( params ) {
return apiGetFrom( '/w/api.php', params, false );
}
/* Cross-origin: use absolute URL + origin=* for CORS */
function apiGetCross( params ) {
return apiGetFrom( MEDIA_API, params, true );
}
/* ── Portal entry point ───────────────────────────────────────── */
mw.hook( 'wikipage.content' ).add( function () {
var page = document.querySelector( '.portal-page' );
if ( !page ) return;
initPortalSlider( page );
initPortalNewest( page );
initPortalStats( page );
initPortalOrphans( page );
initPortalFeatured( page );
} );
/* ════════════════════════════════════════════════════════════════
1. IMAGE CAROUSEL
Reads Portal:NAME/ImageList for a curated file list, falls back
to querying the category directly. Images are fetched via the
media wiki's imageinfo API for thumbnail URLs.
════════════════════════════════════════════════════════════════ */
function initPortalSlider( page ) {
var hero = page.querySelector( '.portal-hero' );
if ( !hero ) return;
var category = hero.dataset.category || '';
if ( !category ) return;
/* Random seed per page load — different slides every visit */
var seed = Math.floor( Math.random() * 2147483647 ) + 1;
/* Query articles from the portal category on en.battlestarwiki.org.
Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */
apiGet( {
action: 'query',
list: 'categorymembers',
cmtitle: 'Category:' + category,
cmtype: 'page',
cmnamespace: '0',
cmlimit: '200'
} ).then( function ( data ) {
var members = ( data.query.categorymembers || [] );
if ( !members.length ) throw new Error( 'empty category' );
/* Daily-seeded shuffle, cap at 20 candidates to fetch images for */
members = seededShuffle( members, seed ).slice( 0, 20 );
var titles = members.map( function ( m ) { return m.title; } ).join( '|' );
/* Fetch pageimage + extract + categories for each article */
return apiGet( {
action: 'query',
titles: titles,
prop: 'pageimages|extracts|categories',
piprop: 'thumbnail',
pithumbsize: '1200',
pilimit: '20',
exintro: '1',
exchars: '200',
exsectionformat: 'plain',
cllimit: '20',
formatversion: '2'
} ).then( function ( d ) {
var slides = [];
( d.query.pages || [] ).forEach( function ( p ) {
/* Skip articles with no image */
if ( !p.thumbnail || !p.thumbnail.source ) return;
var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
if ( extract.length > 180 ) {
extract = extract.replace( /\s\S+$/, '' ) + '…';
}
/* Derive badge label from article categories */
var badge = portalSlideBadge( p.categories || [] );
slides.push( {
thumb: p.thumbnail.source,
caption: extract || p.title,
title: p.title,
link: p.title,
badge: badge
} );
} );
return slides;
} );
} ).then( function ( slides ) {
if ( !slides.length ) throw new Error( 'No slides with images' );
buildCarousel( hero, slides );
} ).catch( function () { /* silent — hero stays dark */ } );
}
/** Map article categories to a human-readable slide badge label.
* Priority order matters — more specific checks first. */
function portalSlideBadge( cats ) {
var names = cats.map( function ( c ) {
return c.title.replace( /^Category:/, '' ).toLowerCase();
} );
/* Exact/prefix matches in priority order */
var checks = [
/* Episodes first — most specific */
[ 'episode guide', 'Episode' ],
[ 'episodes by', 'Episode' ],
[ 'miniseries', 'Episode' ],
/* Ships / Craft */
[ 'colonial craft', 'Ship' ],
[ 'cylon craft', 'Ship' ],
[ 'ships (', 'Ship' ],
[ 'battlestars (', 'Ship' ],
[ 'vipers (', 'Ship' ],
[ 'raptors', 'Ship' ],
/* Characters */
[ 'main characters', 'Character' ],
[ 'recurring guest', 'Character' ],
[ 'one-shot characters', 'Character' ],
[ 'deceased characters', 'Character' ],
[ 'mentioned-only characters', 'Character' ],
[ 'characters (', 'Character' ],
/* Cylons (before military) */
[ 'cylons (', 'Cylon' ],
[ 'cylon military', 'Cylon' ],
[ 'cylon religion', 'Cylon' ],
/* Military */
[ 'colonial military', 'Military' ],
[ 'colonial warriors', 'Military' ],
[ 'gaeta mutineers', 'Military' ],
[ 'fighter squadrons', 'Military' ],
/* Places / Locations */
[ 'planets (', 'Planet' ],
[ 'moons (', 'Moon' ],
[ 'places (', 'Location' ],
[ 'galactica areas', 'Location' ],
[ 'places on ', 'Location' ],
[ 'basestar areas', 'Location' ],
[ 'twelve colonies', 'Location' ],
/* Society / Culture */
[ 'colonial religion', 'Religion' ],
[ 'colonial society', 'Society' ],
[ 'colonial government', 'Government' ],
[ 'colonial history', 'History' ],
[ 'organizations (', 'Organization' ],
[ 'ha'la'tha', 'Organization' ],
[ 'soldiers of the one', 'Organization' ],
/* Technology */
[ 'technology (', 'Technology' ],
[ 'weapons (', 'Weapon' ],
[ 'drugs (', 'Technology' ],
/* Production / Real world */
[ 'cast (', 'Cast' ],
[ 'crew (', 'Crew' ],
[ 'directors (', 'Director' ],
[ 'writers (', 'Writer' ],
[ 'producers (', 'Producer' ],
/* Comics / Books */
[ 'dynamite comics', 'Comic' ],
[ 'comics (', 'Comic' ],
[ 'novels', 'Novel' ],
[ 'books (', 'Book' ],
/* Terminology */
[ 'terminology (', 'Term' ],
[ 'descriptive terms', 'Term' ]
];
for ( var i = 0; i < checks.length; i++ ) {
for ( var j = 0; j < names.length; j++ ) {
if ( names[j].indexOf( checks[i][0] ) !== -1 ) {
return checks[i][1];
}
}
}
return ''; /* empty = fall back to seriesBadge in caller */
}
/** Build and wire the carousel DOM — bsw-hero style */
function buildCarousel( hero, slides ) {
var current = 0;
var total = slides.length;
var timer;
/* Derive portal series label from data-category for the slide badge */
var cat = ( hero.dataset.category || '' ).toUpperCase();
var seriesBadge = cat || 'BSG';
var bg = hero.querySelector( '.portal-hero-bg' );
var caption = hero.querySelector( '.portal-hero-caption' );
var dotsEl = hero.querySelector( '.portal-hero-dots' );
var prevBtn = hero.querySelector( '.portal-hero-prev' );
var nextBtn = hero.querySelector( '.portal-hero-next' );
/* Clickable overlay covering the image area — sits above bg/overlay,
below nav and footer so those remain independently clickable */
var link = hero.querySelector( '.portal-hero-link' );
if ( !link ) {
link = document.createElement( 'a' );
link.className = 'portal-hero-link';
/* Insert after overlay, before content */
var content = hero.querySelector( '.portal-hero-content' );
hero.insertBefore( link, content );
}
/* Build dot indicators */
if ( dotsEl ) {
dotsEl.innerHTML = '';
slides.forEach( function ( _, i ) {
var dot = document.createElement( 'span' );
dot.className = 'portal-hero-dot' + ( i === 0 ? ' is-active' : '' );
dot.setAttribute( 'role', 'button' );
dot.setAttribute( 'tabindex', '0' );
dot.addEventListener( 'click', function () { goTo( i ); } );
dotsEl.appendChild( dot );
} );
}
function goTo( n ) {
current = ( n + total ) % total;
var s = slides[ current ];
/* Set background image on the bg div — same as bsw-slide-bg */
if ( bg ) {
bg.style.backgroundImage = 'url(' + s.thumb + ')';
}
if ( caption ) {
caption.innerHTML =
( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) +
'<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
( s.caption && s.caption !== s.title
? '<div class="portal-hero-extract">' + esc( s.caption ) + '</div>'
: '' );
}
/* Update the clickable overlay link */
if ( link ) {
if ( s.link ) {
link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) );
link.title = s.link;
link.style.display = 'block';
} else {
/* No article link for this slide — disable the overlay */
link.removeAttribute( 'href' );
link.style.display = 'none';
}
}
if ( dotsEl ) {
dotsEl.querySelectorAll( '.portal-hero-dot' ).forEach( function ( d, i ) {
d.classList.toggle( 'is-active', i === current );
} );
}
resetTimer();
}
function resetTimer() {
clearInterval( timer );
timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
}
if ( prevBtn ) {
prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
prevBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
} );
}
if ( nextBtn ) {
nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
nextBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); }
} );
}
hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
hero.addEventListener( 'mouseleave', resetTimer );
/* Touch/swipe support */
var touchX = null;
hero.addEventListener( 'touchstart', function ( e ) {
touchX = e.touches[0].clientX;
}, { passive: true } );
hero.addEventListener( 'touchend', function ( e ) {
if ( touchX === null ) return;
var dx = e.changedTouches[0].clientX - touchX;
if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 );
touchX = null;
}, { passive: true } );
goTo( 0 );
}
/* ════════════════════════════════════════════════════════════════
2. NEWEST ARTICLE (portal-scoped)
Same logevents call as the main page newest widget, but filtered
to the portal's category via a follow-up categorymembers check.
════════════════════════════════════════════════════════════════ */
function initPortalNewest( page ) {
var inner = page.querySelector( '.portal-newest-inner' );
if ( !inner ) return;
var category = inner.dataset.category || '';
if ( !category ) return;
/* Fetch the 30 most recently created mainspace pages */
apiGet( {
action: 'query',
list: 'logevents',
letype: 'create',
lenamespace: '0',
lelimit: '30',
leprop: 'title|timestamp'
} ).then( function ( data ) {
var entries = ( data.query.logevents || [] ).filter( function ( e ) {
return e.title.indexOf( '/' ) === -1 && e.title !== 'Main Page';
} );
if ( !entries.length ) throw new Error( 'No entries' );
/* Check which of these titles are in the portal's category */
var titles = entries.map( function ( e ) { return e.title; } );
/* Build a timestamp map for quick lookup */
var tsMap = {};
entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );
/* Check multiple RDM categories so articles in subcats are matched */
var catCandidates = [
'Category:' + category,
'Category:Characters (RDM)',
'Category:Characters (TRS)',
'Category:Episode Guide (RDM)',
'Category:Colonial Military (RDM)',
'Category:Colonial Craft (RDM)',
'Category:Ships (RDM)',
'Category:Places (RDM)',
'Category:Technology (RDM)',
'Category:Terminology (RDM)',
'Category:Colonial Society (RDM)',
'Category:Colonial History (RDM)',
'Category:Cylons (RDM)'
];
return apiGet( {
action: 'query',
titles: titles.slice( 0, 20 ).join( '|' ),
prop: 'categories',
clcategories: catCandidates.join( '|' ),
cllimit: '20'
} ).then( function ( d ) {
/* Find pages that ARE in any of the candidate categories */
var pages = Object.values( d.query.pages || {} );
var match = pages.find( function ( p ) {
return p.categories && p.categories.length > 0;
} );
/* If none match in the first 20, fall back to the single most recent */
var title = match ? match.title : entries[0].title;
var ts = tsMap[ title ];
return apiGet( {
action: 'query',
titles: title,
prop: 'extracts|pageimages|pageprops',
exintro: '1',
exchars: '400',
exsectionformat: 'plain',
piprop: 'thumbnail',
pithumbsize: '200',
ppprop: 'disambiguation'
} ).then( function ( d2 ) {
var p = Object.values( d2.query.pages || {} )[0] || {};
/* Skip disambiguation, try plain most-recent as fallback */
if ( p.pageprops && p.pageprops.disambiguation !== undefined ) {
title = entries[0].title;
ts = entries[0].timestamp;
return apiGet( {
action: 'query', titles: title,
prop: 'extracts|pageimages', exintro: '1', exchars: '400',
exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200'
} ).then( function ( d3 ) {
return { page: Object.values( d3.query.pages || {} )[0] || {}, ts: ts };
} );
}
return { page: p, ts: ts };
} );
} );
} ).then( function ( result ) {
renderPortalNewest( inner, result.page, result.ts );
} ).catch( function () {
inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-subtle,#888)">Could not load newest article.</div>';
} );
}
function renderPortalNewest( inner, page, ts ) {
var title = page.title || '';
var url = '/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
var thumb = page.thumbnail ? page.thumbnail.source : '';
var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 );
if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026';
var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : '';
var thumbHtml = thumb
? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" style="width:70px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">'
: '';
inner.innerHTML =
'<div style="display:flex;gap:10px;align-items:flex-start">' +
thumbHtml +
'<div>' +
'<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) +
'<div style="font-size:0.8em;color:var(--color-base--subtle,#666);margin-top:3px;line-height:1.5">' + esc( extract ) + '</div>' +
'<div style="margin-top:4px"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link,#3366cc)">Read more \u2192</a></div>' +
'</div>' +
'</div>';
}
/* ════════════════════════════════════════════════════════════════
3. STATS BAR — live article count
The stat_count span with data-category gets its number filled
in from categoryinfo. All other stats are static wikitext.
════════════════════════════════════════════════════════════════ */
function initPortalStats( page ) {
var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
if ( !countEls.length ) return;
/* Batch all stat categories into a single categoryinfo query */
var titles = countEls.length
? Array.prototype.slice.call( countEls ).map( function ( el ) {
return 'Category:' + el.dataset.category;
} )
: [];
apiGet( {
action: 'query',
titles: titles.join( '|' ),
prop: 'categoryinfo',
formatversion: '2'
} ).then( function ( data ) {
var pageMap = {};
( data.query.pages || [] ).forEach( function ( p ) {
pageMap[ p.title ] = p;
} );
countEls.forEach( function ( el ) {
var key = 'Category:' + el.dataset.category;
var p = pageMap[ key ];
if ( p && p.categoryinfo ) {
el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString();
} else {
el.textContent = '\u2014';
}
} );
} ).catch( function () {
countEls.forEach( function ( el ) { el.textContent = '\u2014'; } );
} );
}
/* ════════════════════════════════════════════════════════════════
4. ORPHANED ARTICLES — dynamic, filtered to portal category
Uses the MW API to find articles with no incoming links that
are also members of the portal's stat_category.
════════════════════════════════════════════════════════════════ */
function initPortalOrphans( page ) {
var widget = page.querySelector( '#portal-widget-orphans[data-category]' );
if ( !widget ) return;
var inner = widget.querySelector( '.portal-orphans-inner' );
var category = widget.dataset.category;
if ( !inner || !category ) return;
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';
/* Fetch global lonely pages, then filter to those in this portal's category.
list=querypage is available to all users; qplimit caps at 10 for non-sysops
but we fetch 500 and let the server cap it — more matches for the category. */
apiGet( {
action: 'query',
list: 'querypage',
qppage: 'Lonelypages',
qplimit: '500'
} ).then( function ( data ) {
var lonely = ( ( data.query.querypage || {} ).results || [] )
.map( function ( r ) { return r.title; } );
if ( !lonely.length ) return [];
/* Cross-reference against the portal's category in batches of 50 */
var batch = lonely.slice( 0, 50 ).join( '|' );
return apiGet( {
action: 'query',
titles: batch,
prop: 'categories',
clcategories: 'Category:' + category,
cllimit: '1',
formatversion: '2'
} ).then( function ( d ) {
var orphans = [];
( d.query.pages || [] ).forEach( function ( p ) {
if ( p.missing ) return;
/* categories present = this page IS in the portal category */
if ( p.categories && p.categories.length > 0 ) {
orphans.push( p.title );
}
} );
return orphans;
} );
} ).then( function ( orphans ) {
if ( !orphans.length ) {
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>';
return;
}
inner.innerHTML = orphans.map( function ( t ) {
var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) );
return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>';
} ).join( '' );
} ).catch( function () {
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>';
} );
}
/* ════════════════════════════════════════════════════════════════
5. FEATURED EPISODE SLIDER
Reads a pipe-separated list of episode titles from the widget's
data-episodes attribute (one per season), fetches extract +
thumbnail for each, and builds a carousel.
════════════════════════════════════════════════════════════════ */
function initPortalFeatured( page ) {
var widget = page.querySelector( '#portal-widget-featured[data-episodes]' );
if ( !widget ) return;
var body = widget.querySelector( '.portal-featured-body' );
var dotsEl = widget.querySelector( '.portal-featured-dots' );
var prevBtn = widget.querySelector( '.portal-featured-prev' );
var nextBtn = widget.querySelector( '.portal-featured-next' );
var episodes = ( widget.dataset.episodes || '' ).split( '|' ).map( function ( s ) { return s.trim(); } ).filter( Boolean );
if ( !episodes.length || !body ) return;
body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';
apiGet( {
action: 'query',
titles: episodes.join( '|' ),
prop: 'extracts|pageimages',
exintro: '1',
exchars: '500',
exsectionformat: 'plain',
piprop: 'thumbnail',
pithumbsize: '400',
formatversion: '2'
} ).then( function ( data ) {
var pageMap = {};
( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } );
/* Preserve the requested episode order */
var slides = episodes.map( function ( t ) {
var p = pageMap[ t ] || {};
return {
title: t,
extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ),
thumb: p.thumbnail ? p.thumbnail.source : '',
url: '/' + encodeURIComponent( t.replace( / /g, '_' ) )
};
} );
buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides );
} ).catch( function () {
body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>';
} );
}
function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) {
var current = 0;
var total = slides.length;
function render( n ) {
current = ( n + total ) % total;
var s = slides[ current ];
var thumbHtml = s.thumb
? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">'
: '';
var extract = s.extract;
if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';
body.innerHTML =
'<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' +
'<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' +
'<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' +
'<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>';
if ( dotsEl ) {
dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) {
d.classList.toggle( 'is-active', i === current );
} );
}
}
/* Build dots */
if ( dotsEl ) {
dotsEl.innerHTML = '';
slides.forEach( function ( _, i ) {
var dot = document.createElement( 'span' );
dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' );
dot.setAttribute( 'role', 'button' );
dot.setAttribute( 'tabindex', '0' );
dot.title = 'Season ' + ( i + 1 );
dot.addEventListener( 'click', function () { render( i ); } );
dotsEl.appendChild( dot );
} );
}
if ( prevBtn ) {
prevBtn.addEventListener( 'click', function () { render( current - 1 ); } );
prevBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); }
} );
}
if ( nextBtn ) {
nextBtn.addEventListener( 'click', function () { render( current + 1 ); } );
nextBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); }
} );
}
render( 0 );
}
/* ── Seeded shuffle (stable within a calendar day) ────────────── */
function seededShuffle( arr, seed ) {
var a = arr.slice();
var s = seed;
for ( var i = a.length - 1; i > 0; i-- ) {
/* Xorshift32 */
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
var j = ( ( s >>> 0 ) % ( i + 1 ) );
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
}() );