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 |
||
| Line 1: | Line 1: | ||
/* Portal | /** | ||
* BattlestarWiki — Portal page JavaScript | |||
* Append to MediaWiki:Common.js (after the main page JS block) | |||
* | * | ||
* | * Handles per-portal widgets on any .portal-page: | ||
* | * 1. Image carousel — random images from the portal's image category, | ||
* | * seeded daily so all visitors see the same sequence each day. | ||
* and | * Reads filenames from Portal:NAME/ImageList (one File: per line). | ||
* Falls back to querying the category directly if no list exists. | |||
* 2. Newest article — filtered to the portal's assigned category, | |||
* using the same logevents API call as the main page's newest widget. | |||
* 3. Stats bar — live article count from the portal's category via | |||
* action=query&prop=categoryinfo. Manual counts (episodes, seasons, | |||
* etc.) are static in the wikitext and don't need JS. | |||
* | * | ||
* | * Reuses the apiGet() / apiGetFrom() / dailySeed() / esc() helpers | ||
* | * already defined in the main page JS block above — do not redefine them. | ||
*/ | */ | ||
( function ( | |||
( function () { | |||
'use strict'; | 'use strict'; | ||
/* ── | /* ── Portal entry point ───────────────────────────────────────── */ | ||
mw.hook( 'wikipage.content' ).add( function () { | |||
mw.hook( 'wikipage.content' ).add( function ( | var page = document.querySelector( '.portal-page' ); | ||
if ( !page ) return; | |||
initPortalSlider( page ); | |||
initPortalNewest( page ); | |||
initPortalStats( 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 wrapper = page.querySelector( '.portal-slider-wrapper' ); | |||
var | if ( !wrapper ) return; | ||
var | var nav = wrapper.querySelector( '.portal-slider-nav' ); | ||
var | var category = nav ? nav.dataset.category : ''; | ||
var | var listPage = nav ? nav.dataset.listpage : ''; | ||
var | |||
if ( !listPage && !category ) return; | |||
var seed = dailySeed(); | |||
/* Step 1: get file list — prefer the curated /ImageList sub-page */ | |||
getFileList( listPage, category ) | |||
.then( function ( files ) { | |||
if ( !files.length ) throw new Error( 'No files found' ); | |||
/* Daily-seeded shuffle so order is stable all day */ | |||
files = seededShuffle( files, seed ); | |||
/* Cap at 12 slides — enough for a day's rotation */ | |||
files = files.slice( 0, 12 ); | |||
/* Step 2: fetch thumbnail URLs from the media wiki */ | |||
return fetchThumbnails( files ); | |||
} ) | |||
.then( function ( slides ) { | |||
. | if ( !slides.length ) throw new Error( 'No thumbnails' ); | ||
buildCarousel( wrapper, nav, slides ); | |||
} ) | |||
.catch( function () { | |||
/* Silent fail — the server-rendered first image (from Lua) | |||
remains visible; nav buttons just don't appear */ | |||
if ( nav ) nav.style.display = 'none'; | |||
} ); | |||
} | |||
/** | |||
* Fetch the curated file list from the /ImageList sub-page. | |||
. | * Falls back to a category member query if the page is missing. | ||
*/ | |||
function getFileList( listPage, category ) { | |||
if ( listPage ) { | |||
return apiGet( { | |||
action: 'query', | |||
titles: listPage, | |||
prop: 'revisions', | |||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: '2' | |||
} ).then( function ( data ) { | |||
var pages = data.query && data.query.pages; | |||
if ( !pages || !pages[0] || pages[0].missing ) { | |||
/* Page doesn't exist yet — fall back to category query */ | |||
return category ? getCategoryFiles( category ) : []; | |||
} | |||
var content = pages[0].revisions && | |||
pages[0].revisions[0] && | |||
pages[0].revisions[0].slots && | |||
pages[0].revisions[0].slots.main && | |||
pages[0].revisions[0].slots.main.content || ''; | |||
var files = []; | |||
content.split( '\n' ).forEach( function ( line ) { | |||
line = line.replace( /<!--[\s\S]*?-->/g, '' ).trim(); | |||
if ( !line ) return; | |||
/* Format: File:Name.jpg | optional caption */ | |||
var parts = line.split( '|' ); | |||
var fname = parts[0].trim().replace( /^[Ff]ile:/, '' ); | |||
var caption = parts[1] ? parts[1].trim() : ''; | |||
if ( fname ) files.push( { name: 'File:' + fname, caption: caption } ); | |||
} ); | |||
return files.length ? files : ( category ? getCategoryFiles( category ) : [] ); | |||
} ); | |||
} | } | ||
return category ? getCategoryFiles( category ) : Promise.resolve( [] ); | |||
} | |||
/** Fetch file members of a category from the media wiki */ | |||
function getCategoryFiles( category ) { | |||
return apiGetFrom( MEDIA_API, { | |||
action: 'query', | |||
list: 'categorymembers', | |||
cmtitle: 'Category:' + category, | |||
cmtype: 'file', | |||
cmlimit: '50' | |||
} ).then( function ( data ) { | |||
return ( data.query.categorymembers || [] ).map( function ( m ) { | |||
return { name: m.title, caption: '' }; | |||
} ); | |||
} ); | |||
} | |||
var | /** Fetch thumbnail URLs for a list of file objects via imageinfo */ | ||
function fetchThumbnails( files ) { | |||
/* MW API accepts up to 50 titles per call */ | |||
var titles = files.map( function ( f ) { return f.name; } ).join( '|' ); | |||
var captionMap = {}; | |||
files.forEach( function ( f ) { captionMap[ f.name ] = f.caption; } ); | |||
return apiGetFrom( MEDIA_API, { | |||
action: 'query', | |||
action: | titles: titles, | ||
titles: | prop: 'imageinfo', | ||
prop: | iiprop: 'url|extmetadata', | ||
iiurlwidth: '800', | |||
formatversion: '2' | |||
formatversion: 2 | |||
} ).then( function ( data ) { | } ).then( function ( data ) { | ||
var | var result = []; | ||
( data.query.pages || [] ).forEach( function ( p ) { | |||
var ii = p.imageinfo && p.imageinfo[0]; | |||
if ( !ii || !ii.thumburl ) return; | |||
/* Caption priority: curated list caption → image description metadata */ | |||
var caption = captionMap[ p.title ] || ''; | |||
if ( !caption && ii.extmetadata && ii.extmetadata.ImageDescription ) { | |||
caption = ii.extmetadata.ImageDescription.value.replace( /<[^>]+>/g, '' ); | |||
} | |||
if ( !caption ) caption = p.title.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' ); | |||
result.push( { thumb: ii.thumburl, caption: caption, title: p.title } ); | |||
} ); | |||
return result; | |||
} ); | |||
} | |||
/** Build and wire the carousel DOM */ | |||
function buildCarousel( wrapper, nav, slides ) { | |||
var current = 0; | |||
var total = slides.length; | |||
var timer; | |||
/* Build the main image element — reuse the one Lua rendered if present, | |||
otherwise create it so there's no flash on first load */ | |||
var img = wrapper.querySelector( 'img.portal-slider-image' ); | |||
if ( !img ) { | |||
img = document.createElement( 'img' ); | |||
} | img.className = 'portal-slider-image'; | ||
img.style.cssText = 'display:block;width:100%;border-radius:6px;max-height:260px;object-fit:cover'; | |||
/* Insert before the nav */ | |||
wrapper.insertBefore( img, nav ); | |||
} | |||
/* Caption element */ | |||
var caption = wrapper.querySelector( '.portal-slider-caption' ); | |||
if ( !caption ) { | |||
caption = document.createElement( 'div' ); | |||
caption.className = 'portal-slider-caption'; | |||
caption.style.cssText = 'font-size:0.78em;color:var(--color-subtle,#888);margin-top:4px;min-height:1.2em'; | |||
wrapper.insertBefore( caption, nav ); | |||
} | |||
/* Dots */ | |||
var dotsEl = nav.querySelector( '.portal-slider-dots' ); | |||
var counter = nav.querySelector( '.portal-slider-counter' ); | |||
var prevBtn = nav.querySelector( '.portal-slider-prev' ); | |||
var nextBtn = nav.querySelector( '.portal-slider-next' ); | |||
if ( dotsEl ) { | |||
dotsEl.innerHTML = ''; | |||
var | slides.forEach( function ( _, i ) { | ||
var dot = document.createElement( 'span' ); | |||
dot.className = 'portal-slider-dot' + ( i === 0 ? ' is-active' : '' ); | |||
dot.addEventListener( 'click', function () { goTo( i ); } ); | |||
dotsEl.appendChild( dot ); | |||
} ); | } ); | ||
} | |||
function goTo( n ) { | |||
current = ( n + total ) % total; | |||
var s = slides[ current ]; | |||
img.src = s.thumb; | |||
img.alt = s.caption; | |||
caption.textContent = s.caption; | |||
if ( dotsEl ) { | |||
dotsEl.querySelectorAll( '.portal-slider-dot' ).forEach( function ( d, i ) { | |||
d.classList.toggle( 'is-active', i === current ); | |||
} ); | |||
} | |||
if ( counter ) counter.textContent = ( current + 1 ) + ' / ' + total; | |||
resetTimer(); | |||
} | |||
function resetTimer() { | |||
clearInterval( timer ); | |||
timer = setInterval( function () { goTo( current + 1 ); }, 5000 ); | |||
} | |||
if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } ); | |||
if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } ); | |||
wrapper.addEventListener( 'mouseenter', function () { clearInterval( timer ); } ); | |||
wrapper.addEventListener( 'mouseleave', resetTimer ); | |||
/* Start on slide 0 */ | |||
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 | var tsMap = {}; | ||
entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } ); | |||
return apiGet( { | |||
action: 'query', | |||
. | titles: titles.slice( 0, 20 ).join( '|' ), | ||
prop: 'categories', | |||
clcategories: 'Category:' + category, | |||
cllimit: '1' | |||
} ).then( function ( d ) { | |||
/* Find pages that ARE in the category (have a categories array) */ | |||
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 = 'https://en.battlestarwiki.org/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 | function initPortalStats( page ) { | ||
var countEl = page.querySelector( '.portal-stat-count[data-category]' ); | |||
if ( !countEl ) return; | |||
var category = countEl.dataset.category; | |||
} | apiGet( { | ||
action: 'query', | |||
titles: 'Category:' + category, | |||
prop: 'categoryinfo' | |||
} ).then( function ( data ) { | |||
var pages = Object.values( data.query.pages || {} ); | |||
if ( !pages[0] || !pages[0].categoryinfo ) return; | |||
var n = pages[0].categoryinfo.pages || 0; | |||
countEl.textContent = n.toLocaleString(); | |||
} ).catch( function () { | |||
countEl.textContent = '\u2014'; | |||
} ); | |||
} | |||
/* ── Seeded shuffle (stable within a calendar day) ────────────── */ | |||
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 384: | ||
} | } | ||
}( | }() ); | ||
Revision as of 20:34, 13 April 2026
/**
* BattlestarWiki — Portal page JavaScript
* Append to MediaWiki:Common.js (after the main page JS block)
*
* Handles per-portal widgets on any .portal-page:
* 1. Image carousel — random images from the portal's image category,
* seeded daily so all visitors see the same sequence each day.
* Reads filenames from Portal:NAME/ImageList (one File: per line).
* Falls back to querying the category directly if no list exists.
* 2. Newest article — filtered to the portal's assigned category,
* using the same logevents API call as the main page's newest widget.
* 3. Stats bar — live article count from the portal's category via
* action=query&prop=categoryinfo. Manual counts (episodes, seasons,
* etc.) are static in the wikitext and don't need JS.
*
* Reuses the apiGet() / apiGetFrom() / dailySeed() / esc() helpers
* already defined in the main page JS block above — do not redefine them.
*/
( function () {
'use strict';
/* ── Portal entry point ───────────────────────────────────────── */
mw.hook( 'wikipage.content' ).add( function () {
var page = document.querySelector( '.portal-page' );
if ( !page ) return;
initPortalSlider( page );
initPortalNewest( page );
initPortalStats( 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 wrapper = page.querySelector( '.portal-slider-wrapper' );
if ( !wrapper ) return;
var nav = wrapper.querySelector( '.portal-slider-nav' );
var category = nav ? nav.dataset.category : '';
var listPage = nav ? nav.dataset.listpage : '';
if ( !listPage && !category ) return;
var seed = dailySeed();
/* Step 1: get file list — prefer the curated /ImageList sub-page */
getFileList( listPage, category )
.then( function ( files ) {
if ( !files.length ) throw new Error( 'No files found' );
/* Daily-seeded shuffle so order is stable all day */
files = seededShuffle( files, seed );
/* Cap at 12 slides — enough for a day's rotation */
files = files.slice( 0, 12 );
/* Step 2: fetch thumbnail URLs from the media wiki */
return fetchThumbnails( files );
} )
.then( function ( slides ) {
if ( !slides.length ) throw new Error( 'No thumbnails' );
buildCarousel( wrapper, nav, slides );
} )
.catch( function () {
/* Silent fail — the server-rendered first image (from Lua)
remains visible; nav buttons just don't appear */
if ( nav ) nav.style.display = 'none';
} );
}
/**
* Fetch the curated file list from the /ImageList sub-page.
* Falls back to a category member query if the page is missing.
*/
function getFileList( listPage, category ) {
if ( listPage ) {
return apiGet( {
action: 'query',
titles: listPage,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
formatversion: '2'
} ).then( function ( data ) {
var pages = data.query && data.query.pages;
if ( !pages || !pages[0] || pages[0].missing ) {
/* Page doesn't exist yet — fall back to category query */
return category ? getCategoryFiles( category ) : [];
}
var content = pages[0].revisions &&
pages[0].revisions[0] &&
pages[0].revisions[0].slots &&
pages[0].revisions[0].slots.main &&
pages[0].revisions[0].slots.main.content || '';
var files = [];
content.split( '\n' ).forEach( function ( line ) {
line = line.replace( /<!--[\s\S]*?-->/g, '' ).trim();
if ( !line ) return;
/* Format: File:Name.jpg | optional caption */
var parts = line.split( '|' );
var fname = parts[0].trim().replace( /^[Ff]ile:/, '' );
var caption = parts[1] ? parts[1].trim() : '';
if ( fname ) files.push( { name: 'File:' + fname, caption: caption } );
} );
return files.length ? files : ( category ? getCategoryFiles( category ) : [] );
} );
}
return category ? getCategoryFiles( category ) : Promise.resolve( [] );
}
/** Fetch file members of a category from the media wiki */
function getCategoryFiles( category ) {
return apiGetFrom( MEDIA_API, {
action: 'query',
list: 'categorymembers',
cmtitle: 'Category:' + category,
cmtype: 'file',
cmlimit: '50'
} ).then( function ( data ) {
return ( data.query.categorymembers || [] ).map( function ( m ) {
return { name: m.title, caption: '' };
} );
} );
}
/** Fetch thumbnail URLs for a list of file objects via imageinfo */
function fetchThumbnails( files ) {
/* MW API accepts up to 50 titles per call */
var titles = files.map( function ( f ) { return f.name; } ).join( '|' );
var captionMap = {};
files.forEach( function ( f ) { captionMap[ f.name ] = f.caption; } );
return apiGetFrom( MEDIA_API, {
action: 'query',
titles: titles,
prop: 'imageinfo',
iiprop: 'url|extmetadata',
iiurlwidth: '800',
formatversion: '2'
} ).then( function ( data ) {
var result = [];
( data.query.pages || [] ).forEach( function ( p ) {
var ii = p.imageinfo && p.imageinfo[0];
if ( !ii || !ii.thumburl ) return;
/* Caption priority: curated list caption → image description metadata */
var caption = captionMap[ p.title ] || '';
if ( !caption && ii.extmetadata && ii.extmetadata.ImageDescription ) {
caption = ii.extmetadata.ImageDescription.value.replace( /<[^>]+>/g, '' );
}
if ( !caption ) caption = p.title.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' );
result.push( { thumb: ii.thumburl, caption: caption, title: p.title } );
} );
return result;
} );
}
/** Build and wire the carousel DOM */
function buildCarousel( wrapper, nav, slides ) {
var current = 0;
var total = slides.length;
var timer;
/* Build the main image element — reuse the one Lua rendered if present,
otherwise create it so there's no flash on first load */
var img = wrapper.querySelector( 'img.portal-slider-image' );
if ( !img ) {
img = document.createElement( 'img' );
img.className = 'portal-slider-image';
img.style.cssText = 'display:block;width:100%;border-radius:6px;max-height:260px;object-fit:cover';
/* Insert before the nav */
wrapper.insertBefore( img, nav );
}
/* Caption element */
var caption = wrapper.querySelector( '.portal-slider-caption' );
if ( !caption ) {
caption = document.createElement( 'div' );
caption.className = 'portal-slider-caption';
caption.style.cssText = 'font-size:0.78em;color:var(--color-subtle,#888);margin-top:4px;min-height:1.2em';
wrapper.insertBefore( caption, nav );
}
/* Dots */
var dotsEl = nav.querySelector( '.portal-slider-dots' );
var counter = nav.querySelector( '.portal-slider-counter' );
var prevBtn = nav.querySelector( '.portal-slider-prev' );
var nextBtn = nav.querySelector( '.portal-slider-next' );
if ( dotsEl ) {
dotsEl.innerHTML = '';
slides.forEach( function ( _, i ) {
var dot = document.createElement( 'span' );
dot.className = 'portal-slider-dot' + ( i === 0 ? ' is-active' : '' );
dot.addEventListener( 'click', function () { goTo( i ); } );
dotsEl.appendChild( dot );
} );
}
function goTo( n ) {
current = ( n + total ) % total;
var s = slides[ current ];
img.src = s.thumb;
img.alt = s.caption;
caption.textContent = s.caption;
if ( dotsEl ) {
dotsEl.querySelectorAll( '.portal-slider-dot' ).forEach( function ( d, i ) {
d.classList.toggle( 'is-active', i === current );
} );
}
if ( counter ) counter.textContent = ( current + 1 ) + ' / ' + total;
resetTimer();
}
function resetTimer() {
clearInterval( timer );
timer = setInterval( function () { goTo( current + 1 ); }, 5000 );
}
if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
wrapper.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
wrapper.addEventListener( 'mouseleave', resetTimer );
/* Start on slide 0 */
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; } );
return apiGet( {
action: 'query',
titles: titles.slice( 0, 20 ).join( '|' ),
prop: 'categories',
clcategories: 'Category:' + category,
cllimit: '1'
} ).then( function ( d ) {
/* Find pages that ARE in the category (have a categories array) */
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 = 'https://en.battlestarwiki.org/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 countEl = page.querySelector( '.portal-stat-count[data-category]' );
if ( !countEl ) return;
var category = countEl.dataset.category;
apiGet( {
action: 'query',
titles: 'Category:' + category,
prop: 'categoryinfo'
} ).then( function ( data ) {
var pages = Object.values( data.query.pages || {} );
if ( !pages[0] || !pages[0].categoryinfo ) return;
var n = pages[0].categoryinfo.pages || 0;
countEl.textContent = n.toLocaleString();
} ).catch( function () {
countEl.textContent = '\u2014';
} );
}
/* ── 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;
}
}() );